Skip to content
Open
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
2 changes: 1 addition & 1 deletion .platform-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
063589bf0adbe1a8823a943cee95385b423b0e3f
03945d3aa75a250a2f3584e06804a3338f29610c
4 changes: 4 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,7 @@
AA04A0012FF4000000000001 /* DashConnectDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */; };
AA05A0012FF5000000000001 /* DashConnectStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */; };
AA06A0112FF6000000000011 /* AppConnectV2LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06A0132FF6000000000013 /* AppConnectV2LoginTests.swift */; };
AA06A0212FF6000000000021 /* AppConnectEncryptionRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06A0232FF6000000000023 /* AppConnectEncryptionRotationTests.swift */; };
AA06A0012FF6000000000001 /* PlatformDashConnectDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */; };
AA22BB33CC44DD55EE66FF78 /* BuyReceiveFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA22BB33CC44DD55EE66FF77 /* BuyReceiveFlowUITests.swift */; };
AA29B7041CF55787428E1A04 /* libPods-WatchApp Extension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B9BF7550EF8D4BA009CB1EB5 /* libPods-WatchApp Extension.a */; };
Expand Down Expand Up @@ -3366,6 +3367,7 @@
AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectDataSourceTests.swift; path = DashConnect/DashConnectDataSourceTests.swift; sourceTree = "<group>"; };
AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectStoreTests.swift; path = DashConnect/DashConnectStoreTests.swift; sourceTree = "<group>"; };
AA06A0132FF6000000000013 /* AppConnectV2LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConnectV2LoginTests.swift; path = DashConnect/AppConnectV2LoginTests.swift; sourceTree = "<group>"; };
AA06A0232FF6000000000023 /* AppConnectEncryptionRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConnectEncryptionRotationTests.swift; path = DashConnect/AppConnectEncryptionRotationTests.swift; sourceTree = "<group>"; };
AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlatformDashConnectDataSourceTests.swift; path = DashConnect/PlatformDashConnectDataSourceTests.swift; sourceTree = "<group>"; };
AA22BB33CC44DD55EE66FF77 /* BuyReceiveFlowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuyReceiveFlowUITests.swift; sourceTree = "<group>"; };
AA29A8515217AC4232F794C2 /* Pods-TodayExtension.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TodayExtension.testnet.xcconfig"; path = "Target Support Files/Pods-TodayExtension/Pods-TodayExtension.testnet.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7575,6 +7577,7 @@
AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */,
AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */,
AA06A0132FF6000000000013 /* AppConnectV2LoginTests.swift */,
AA06A0232FF6000000000023 /* AppConnectEncryptionRotationTests.swift */,
AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */,
47976E492987772700612988 /* AmountObjectTests.swift */,
A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */,
Expand Down Expand Up @@ -10908,6 +10911,7 @@
AA04A0012FF4000000000001 /* DashConnectDataSourceTests.swift in Sources */,
AA05A0012FF5000000000001 /* DashConnectStoreTests.swift in Sources */,
AA06A0112FF6000000000011 /* AppConnectV2LoginTests.swift in Sources */,
AA06A0212FF6000000000021 /* AppConnectEncryptionRotationTests.swift in Sources */,
AA06A0012FF6000000000001 /* PlatformDashConnectDataSourceTests.swift in Sources */,
47976E4A2987772700612988 /* AmountObjectTests.swift in Sources */,
A17EED0126C7000000000001 /* WalletWipeSerialExecutorTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,45 +371,41 @@ final class SwiftDashSDKHost {

/// The `DashSDKConfig.platform_version` to build the SDK with, per network.
///
/// **Testnet stays pinned to 13.** Testnet Drive moved past v12 to v13,
/// whose validation set changed the shielded identity-create exit
/// denominations (v12/V8 `[0.1, 0.3, 0.5, 1.0]` → v13/V9 `[0.03, 0.1,
/// 0.25, 0.5, 1.0]`). Staying at v12 made the client build shielded
/// transitions against the old set, so a contested (0.25 DASH) shielded
/// username create failed client-side ("denomination 25000000000 is not a
/// member …") even though the server requires exactly 0.25. Pinning
/// explicitly also avoids a race where the shielded build runs before the
/// SDK has ratcheted to the network version. Bump this when the agreed
/// testnet protocol moves past v13.
/// **Testnet is pinned to the protocol testnet has activated** (14; check
/// `current` in an evonode's getStatus before bumping). Below it the
/// client builds transitions the network refuses — at 13, every
/// `ShieldFromAssetLock` (version 1 only at v14) failed with 10603 and
/// left its asset lock unspent. An explicit pin, rather than auto-detect,
/// keeps a shielded build from running before the SDK has ratcheted up to
/// the network version. A pinned SDK never ratchets
/// (`refreshProtocolVersion()` is a no-op while pinned), so the pin has to
/// be bumped by hand on the next testnet activation.
///
/// **Mainnet uses `0` — auto-detect.** The pin above was chosen for
/// testnet but applied to every network, which put the client two versions
/// ahead of mainnet. Platform v13 (`DRIVE_VERSION_V8`) switches the
/// compacted address-balance proof to the two-proof
/// `CompactedAddressBalanceProof` bincode envelope, while "nodes and
/// clients on v12 and below keep the legacy single GroveDB proof"
/// (rs-platform-version `version/v13.rs`). A v13 client therefore decodes
/// mainnet's legacy proof as the envelope, consumes the structure and
/// trips on the remainder:
/// **Mainnet and devnet use `0` — auto-detect.** The SDK seeds at its
/// per-network `min_protocol_version` floor (13 on mainnet) and moves up
/// only when a proved response carries a newer version, so it follows a
/// mainnet activation to any protocol the linked SDK carries without an
/// app release. The floor must not sit
/// above the network: a client ahead of the network misreads its proofs (a
/// v13 pin while mainnet ran v12 failed with "compacted address balance
/// proof contains trailing bytes"). Devnet's floor is 14, built from the
/// linked SDK's v14, which a devnet cut from another release may not run.
/// TODO(protocol-refresh): the app never calls `refreshProtocolVersion()`,
/// so until the first proved read an auto-detect SDK builds transitions at
/// its floor.
///
/// proof: corrupted error: compacted address balance proof contains trailing bytes
/// **Regtest** returns `0` only to keep the switch exhaustive;
/// `makeRuntime` rejects it before an SDK is built.
///
/// — observed 20× across three nodes on a mainnet device (2026-07-31),
/// with testnet clean, and it retries then gives up, so the Platform
/// address balance never populates there.
///
/// `0` is what the SDK is designed for: it seeds at the per-network
/// `min_protocol_version` floor (mainnet 11, testnet 12) with auto-detect
/// on, ratcheting up as the network reports newer versions — "so this
/// picks the right wire without a Swift-side network→version map"
/// (`SDK.init(network:platformVersion:)`). A hard pin also disables
/// `refreshProtocolVersion()`, which is documented as a no-op while
/// pinned, so the client could never learn the network's real version.
///
/// Devnet/regtest follow mainnet's auto-detect: no shielded-denomination
/// contract is pinned for them, and `makeRuntime` rejects regtest anyway.
/// The pin only names a version; its rules come from the linked SDK.
/// platform v4.2-dev carries a pre-release v14 whose `ShieldFromAssetLock`
/// is still version 0. Testnet runs 5.0.0-rc.4; link that release, or a
/// later commit with the same `v14.rs`.
static func platformVersion(for network: Network) -> UInt32 {
network == .testnet ? 13 : 0
switch network {
case .testnet: return 14
case .mainnet, .devnet, .regtest: return 0
}
}

// MARK: - Wallet presence
Expand Down
157 changes: 130 additions & 27 deletions DashWallet/Sources/Models/DashConnect/AppConnectV2Login.swift
Original file line number Diff line number Diff line change
Expand Up @@ -358,17 +358,110 @@ extension AppConnect {
)
}

/// One encryption key this wallet derives, as the data source holds it:
/// the private key is wiped by whoever owns the array it ends up in.
typealias DerivedEncryptionKey = (publicKey: Data, privateKey: Data)

/// DIP "Encryption Keys": every key this wallet can derive for one
/// encryption binding of `purpose`. The first is always rotation `0'` at
/// `m/9'/coin'/5'/7'/0'/identityId'/contractId'/purpose'/rotation'`, the
/// key a login registers when the identity has none for the binding.
/// Rotations are registered in order, so the later ones are found by
/// deriving `1'`, `2'`, … and stopping at the first whose key is not on
/// the identity in any state; that one is wiped, not kept. No more
/// rotations are derived than the identity has keys of `purpose`.
///
/// On testnet and devnet the key at the path without the rotation level
/// comes last: wallets built before the DIP registered keys there, and a
/// current key at that path is still sent. Mainnet never had them, so the
/// path is not derived there and such a key reads as another wallet's.
static func derivableEncryptionKeys(
network: Network,
purpose: KeyPurpose,
currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo],
rotation: (UInt32) throws -> DerivedEncryptionKey,
legacy: () throws -> DerivedEncryptionKey
) throws -> [DerivedEncryptionKey] {
func isOnIdentity(_ publicKey: Data) -> Bool {
currentIdentityPublicKeys.contains { $0.purpose == purpose && $0.data == publicKey }
}
var keys: [DerivedEncryptionKey] = []
var kept = false
defer {
if !kept { for index in keys.indices { PlatformDashConnectDataSource.zero(&keys[index].privateKey) } }
}
keys.append(try rotation(0))
let maxRotations = max(1, currentIdentityPublicKeys.filter { $0.purpose == purpose }.count)
var next: UInt32 = 1
while isOnIdentity(keys[keys.count - 1].publicKey), keys.count < maxRotations {
var key = try rotation(next)
guard isOnIdentity(key.publicKey) else {
PlatformDashConnectDataSource.zero(&key.privateKey)
break
}
keys.append(key)
next += 1
}
if derivesLegacyEncryptionPath(network) {
keys.append(try legacy())
}
kept = true
return keys
}

/// Whether `network` derives encryption keys at the path without the
/// rotation level as well (DIP "Deployment and Compatibility").
static func derivesLegacyEncryptionPath(_ network: Network) -> Bool {
network == .testnet || network == .devnet
}

/// What a login does with one granted encryption binding.
enum EncryptionSlot: Equatable {
/// Send the identity's current key, which is `derivedIndex` among
/// the keys `derivableEncryptionKeys` returned; register nothing.
case send(keyId: UInt32, derivedIndex: Int)
/// Register rotation `0'` (the first derivable key) and send it.
case register
/// Leave the slot out and register nothing.
case omit
}

/// The slot a settled `state` comes to, or nil for one that is not
/// settled, or a current key that is not among `derivedPublicKeys` as
/// the fetched keys show it (a read that is behind).
static func encryptionSlot(
for state: EncryptionKeyState,
derivedPublicKeys: [Data],
currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo]
) -> EncryptionSlot? {
switch state {
case .current(let keyId):
guard let key = currentIdentityPublicKeys.first(where: { UInt32(bitPattern: $0.keyId) == keyId }),
let derivedIndex = derivedPublicKeys.firstIndex(of: key.data) else { return nil }
return .send(keyId: keyId, derivedIndex: derivedIndex)
case .absent:
return .register
case .notThisWallets:
return .omit
case .outOfDate, .absentIfSettled, .notThisWalletsIfSettled, .currentIfSettled:
return nil
}
}

/// What the identity holds for the app's encryption key of one purpose
/// (wallet rule 7).
/// (wallet rule 7, DIP "Encryption Keys").
enum EncryptionKeyState: Equatable {
/// Its current key is the one this wallet derives: send it again.
/// Its current key is one this wallet derives: send it again.
case current(keyId: UInt32)
/// It has none: register the one this wallet derives.
/// It has no key for the contract and purpose at all: register
/// rotation `0'`.
case absent
/// Its current key is another wallet's, or the one this wallet
/// derives was disabled: leave the slot out. Replacing it would be a
/// rotation, which orphans what peers encrypted to it; that is an
/// explicit user action, never part of a login.
/// Its current key is another wallet's, or Platform's current key
/// for the contract and purpose is disabled, so none is current:
/// leave the slot out.
/// Replacing it would be a rotation, which orphans what peers
/// encrypted to it; that is an explicit user action, never part of a
/// login.
case notThisWallets
/// The fetched keys and Platform's current key disagree: one of the
/// two reads is behind, and nothing can be said until both agree.
Expand Down Expand Up @@ -583,22 +676,27 @@ extension AppConnect {
isDisabled: !(disabledAt == nil || disabledAt is NSNull))
}

/// Rotation 0 only: the key at DIP-13 `7'` for (identity, contract,
/// purpose). `currentKey` is the key Platform names as current under
/// the app's `rule`; this wallet's key is sent only while it is that one
/// and live. The fetched keys must show that key as Platform does.
/// DIP "Encryption Keys", for one granted binding. `currentKey` is the
/// key Platform names as current under the app's `rule`; it is the
/// identity's current key only while it is live. `derivedPublicKeys` are
/// every key this wallet derives for the binding
/// (`derivableEncryptionKeys`). A live current key among them is sent;
/// any other live current key is another wallet's and its slot is left
/// out (rule 1). A disabled current key means a key for the contract and
/// purpose is on the identity and none is current: the slot is left out
/// and nothing is registered (rule 2). Only with no current key at all is a
/// key registered (rule 3). The fetched keys must show the current key
/// as Platform does.
///
/// Every key bound to the app becomes current when it is registered, so
/// with no current key at all a live key the store places on the app
/// means Platform's read is behind, and a key it cannot place is
/// bound elsewhere once a later read agrees. Beside a current key, a
/// live key the app may hold was registered before it, or after it with
/// Platform's read behind, so the answer waits on a later read. Behind
/// a disabled current key that a later read confirms, a key registered
/// before it is no longer current, and a new one shadows nothing; under
/// `unique`, nothing can be added behind a disabled current key at all.
/// key the app may hold was registered before it, or after it with
/// Platform's read behind, so under `latest` the answer waits on a later
/// read.
static func encryptionKeyState(
derivedPublicKey: Data,
derivedPublicKeys: [Data],
purpose: KeyPurpose,
contractId: Data,
rule: CurrentKeyRule,
Expand All @@ -620,13 +718,16 @@ extension AppConnect {
unplaced = true
}
}
let holdsTheDerivedKey = currentIdentityPublicKeys.contains(where: { $0.data == derivedPublicKey })
guard let currentKey else {
// Registering a key bound to the app makes it current, so a key
// the store places on the app with no current key at all, live or
// disabled, or this wallet's key in any state (it is derived for
// the app's contract), is a read from before it landed.
if placedInAnyState || holdsTheDerivedKey { return .outOfDate }
// disabled, or one of this wallet's keys in any state (they are
// derived for the app's contract), is a read from before it
// landed.
let holdsADerivedKey = currentIdentityPublicKeys.contains(where: {
$0.purpose == purpose && derivedPublicKeys.contains($0.data)
})
if placedInAnyState || holdsADerivedKey { return .outOfDate }
return unplaced ? .absentIfSettled : .absent
}
guard let fetched = currentIdentityPublicKeys.first(where: { UInt32(bitPattern: $0.keyId) == currentKey.keyId }),
Expand All @@ -640,17 +741,19 @@ extension AppConnect {
let mayBeBehind = rule == .latest && (placedInAnyState || unplaced)
if !currentKey.isDisabled {
guard fetched.keyType == .ecdsaSecp256k1, currentKey.keyType == .ecdsaSecp256k1,
currentKey.data == derivedPublicKey else {
derivedPublicKeys.contains(currentKey.data) else {
return mayBeBehind ? .notThisWalletsIfSettled : .notThisWallets
}
return mayBeBehind ? .currentIfSettled(keyId: currentKey.keyId) : .current(keyId: currentKey.keyId)
}
// Rule 2: the identity has a key for the contract and purpose, and
// none is current. Under `unique` Platform refuses any other; under
// `latest` a new key would be a rotation, which is the user's
// explicit action and never part of a login. A later live key with
// Platform's read behind is the one exception, so under `latest`
// the answer waits on a later read.
if rule == .unique { return .notThisWallets }
// Behind a disabled current key, a key of the app's registered
// before it is no longer current either, so a new key shadows
// nothing; this wallet's own key cannot be added a second time.
if holdsTheDerivedKey { return mayBeBehind ? .notThisWalletsIfSettled : .notThisWallets }
return mayBeBehind ? .absentIfSettled : .absent
return mayBeBehind ? .notThisWalletsIfSettled : .notThisWallets
}

/// Wallet rule 8: the live auth keys of this wallet's earlier grants to
Expand Down
Loading
Loading