diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Account.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Account.swift index 8b6b2ff803a..7568797a51e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Account.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/Account.swift @@ -20,16 +20,31 @@ public class Account { // MARK: - Derivation (account-based) - /// Derive a private key (WIF) using this account and a master xpriv derived from the given path. + /// Derive a private key (WIF) at `index` under this account. + /// + /// The account's own derivation path is applied by the FFI: + /// `account_derive_private_key_as_wif_at` → `derive_xpriv_from_master_xpriv`, + /// which resolves `Account::derivation_path()` — the FULL path from the + /// wallet master (e.g. `m/9'/5'/3'/1'` for provider voting keys). So this + /// hands it the wallet **master** and takes no path argument. + /// + /// It previously accepted a `masterPath` and pre-derived it, applying the + /// account path twice: a caller passing the account root got the key at + /// `m/9'/5'/3'/1'/9'/5'/3'/1'/index` instead of `m/9'/5'/3'/1'/index`. + /// Nothing failed locally — the key was well-formed and deterministic, just + /// not the account's key at that index. That is only detectable against + /// something holding the real one: a masternode vote signed with it is + /// rejected as having no voter identity, since the voter identity is + /// derived from the signing key's own hash160. + /// /// - Parameters: - /// - wallet: The parent wallet used to derive the master extended private key - /// - masterPath: The account root derivation path (e.g., "m/9'/5'/3'/1'") + /// - wallet: The parent wallet, used for its master extended private key /// - index: The child index to derive (e.g., 0 for the first key) /// - Returns: The private key encoded as WIF - public func derivePrivateKeyWIF(wallet: Wallet, masterPath: String, index: UInt32) throws -> String { + public func derivePrivateKeyWIF(wallet: Wallet, index: UInt32) throws -> String { var error = FFIError() - // Derive master extended private key for this account root - let masterPtr = masterPath.withCString { pathCStr in + // "m" parses to the empty derivation path — the master key itself. + let masterPtr = "m".withCString { pathCStr in wallet_derive_extended_private_key(wallet.ffiHandle, pathCStr, &error) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AccountDerivationPathTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AccountDerivationPathTests.swift new file mode 100644 index 00000000000..ce9016ca09f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AccountDerivationPathTests.swift @@ -0,0 +1,90 @@ +import XCTest + +@testable import SwiftDashSDK + +/// `Account.derivePrivateKeyWIF(wallet:index:)` must apply the account's +/// derivation path exactly once. +/// +/// The FFI resolves `Account::derivation_path()` itself — the full path from +/// the wallet master — so the wrapper has to hand it the master. It used to +/// take a `masterPath`, pre-derive that, and pass the result, which applied the +/// account path twice: provider voting keys came back from +/// `m/9'/5'/3'/1'/9'/5'/3'/1'/index` instead of `m/9'/5'/3'/1'/index`. +/// +/// Nothing failed locally when that happened. The key was well-formed and +/// derived deterministically, so it round-tripped through WIF parsing and +/// signing without complaint — it simply wasn't the account's key at that +/// index. Only a counterparty holding the real key could tell: a masternode +/// vote signed with it is rejected as having no voter identity, because the +/// voter identity is derived from the signing key's own hash160. +/// +/// So these pin account-based derivation against the explicit DIP-3 path, +/// which is the thing the doubling silently broke. +final class AccountDerivationPathTests: XCTestCase { + /// Standard BIP39 test vector, matching the other wallet tests here. + private let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + private func assertAccountDerivationMatchesPath( + accountType: AccountType, + pathPrefix: String, + network: Network, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let wallet = try Wallet(mnemonic: mnemonic, network: network) + let account = try wallet.getAccount(type: accountType) + + // Indexes beyond 0 matter: the doubled path agreed with nothing, but a + // single wrong level could still coincide at index 0 in principle. + for index in [UInt32(0), 1, 19] { + let viaAccount = try account.derivePrivateKeyWIF(wallet: wallet, index: index) + let viaPath = try wallet.derivePrivateKey(path: "\(pathPrefix)/\(index)") + XCTAssertEqual( + viaAccount, viaPath, + """ + account-based derivation at index \(index) disagrees with \ + \(pathPrefix)/\(index) — the account path is being applied the \ + wrong number of times + """, + file: file, line: line) + } + } + + func testProviderVotingKeysDeriveAtTheDIP3Path() throws { + try assertAccountDerivationMatchesPath( + accountType: .providerVotingKeys, + pathPrefix: "m/9'/5'/3'/1'", + network: .mainnet) + } + + func testProviderOwnerKeysDeriveAtTheDIP3Path() throws { + try assertAccountDerivationMatchesPath( + accountType: .providerOwnerKeys, + pathPrefix: "m/9'/5'/3'/2'", + network: .mainnet) + } + + /// The account resolves its own path, including coin type, so testnet must + /// land on `1'` without the caller saying so. + func testProviderVotingKeysUseTheTestnetCoinType() throws { + try assertAccountDerivationMatchesPath( + accountType: .providerVotingKeys, + pathPrefix: "m/9'/1'/3'/1'", + network: .testnet) + } + + /// The specific regression: deriving against the account root rather than + /// the master must NOT reproduce account-based derivation. If these ever + /// match, the wrapper has gone back to pre-deriving the account path. + func testDoubledAccountPathIsNotWhatWeDerive() throws { + let wallet = try Wallet(mnemonic: mnemonic, network: .mainnet) + let account = try wallet.getAccount(type: .providerVotingKeys) + + let correct = try account.derivePrivateKeyWIF(wallet: wallet, index: 19) + let doubled = try wallet.derivePrivateKey(path: "m/9'/5'/3'/1'/9'/5'/3'/1'/19") + XCTAssertNotEqual( + correct, doubled, + "account-based derivation is applying the account path twice again") + } +}