Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions packages/swift-sdk/Sources/SwiftDashSDK/Address/Addresses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines 77 to 86

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Bech32m fetch APIs accept non-dash/tdash HRPs

getInfo(bech32mAddress:) decodes any bech32m string and only validates the 21-byte payload / type byte before querying the FFI. A caller passing a bech32m string with an arbitrary HRP but a coincidentally-valid [0xb0|0x80] || 20-byte hash payload is accepted and queried as a Platform address, even though the Rust side (PlatformAddress::from_bech32m_string) and AddressTransformer.parseBech32mAddress both reject non-dash/tdash HRPs. Apply the same HRP check here and in getInfos(bech32mAddresses:) at line 222 for API consistency with the rest of this PR.

Suggested change
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)
}
public func getInfo(bech32mAddress: String) throws -> PlatformAddressInfo? {
guard let decoded = Bech32m.decode(bech32mAddress),
decoded.hrp == Bech32m.platformHrpMainnet || decoded.hrp == Bech32m.platformHrpTestnet else {
throw SDKError.invalidParameter("Invalid bech32m Platform address")
}
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: storageBytes)
}

source: ['codex']


/// 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)
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines 435 to 442

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: isValidPlatformAddress doesn't check the DIP-0018 type byte

The validator now checks the canonical HRP and the 21-byte length, but does not verify that the payload's leading byte is one of the DIP-0018 Platform type bytes (0xb0 P2PKH or 0x80 P2SH). That leaves the public validator (and any UI chip built on it) reporting true for well-formed bech32m strings whose first byte then causes storageBytes(fromBech32mPayload:) and the FFI fetch path to immediately reject the address — a mismatch this PR otherwise closes. Reuse storageBytes(fromBech32mPayload:) to gate on both length and type byte.

Suggested change
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
}
public static func isValidPlatformAddress(_ address: String) -> Bool {
guard let result = decode(address) else {
return false
}
// Valid Platform addresses have the DIP-0018 dash/tdash HRP and a
// 21-byte payload with a P2PKH (0xb0) or P2SH (0x80) type byte.
let validHrp = result.hrp == platformHrpMainnet || result.hrp == platformHrpTestnet
return validHrp && storageBytes(fromBech32mPayload: result.data) != nil
}

source: ['codex']

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading