diff --git a/DashWallet/Sources/Models/DashConnect/AppConnectLogin.swift b/DashWallet/Sources/Models/DashConnect/AppConnectLogin.swift new file mode 100644 index 0000000000..5174d9fe10 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/AppConnectLogin.swift @@ -0,0 +1,729 @@ +import CryptoKit +import Foundation +import os +import SwiftDashSDK + +/// The one-QR login (DashConnect v2): the wallet derives a session key for +/// the request, registers it on the identity, and only then publishes the +/// encrypted key to the App Connect system contract. There is no `dash-st:` +/// step. +/// +/// `AppConnect` holds the rules and is pure; `PlatformDashConnectDataSource` +/// owns the Platform calls and their order. The grant records and their +/// `UserDefaults` store are at the end of the file. +enum AppConnect { + /// The protocol version that registers the App Connect system contract. + /// Below it the contract does not exist and the two-QR login is the only + /// one that can work. + static let minimumProtocolVersion: UInt32 = 14 + + /// Same id on every network (`docs/protocol/app-connect.md` in + /// dashpay/platform). + static let contractId = "H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ" + static let documentType = PlatformDashConnectDataSource.loginKeyExchangeDocumentType + + /// Limits put on every session key. + /// TODO(app-connect-limits): the protocol leaves both values to the + /// wallet and nothing has fixed them yet; they are not user-selectable. + static let sessionKeyBudgetCredits: UInt64 = 5_000_000_000 // 0.05 DASH + static let sessionKeyLifetime: TimeInterval = 90 * 24 * 60 * 60 + + /// What a one-QR approval gives the app: how many of the identity's + /// credits its key may spend and for how long the key works. The approval + /// sheet shows these values and the key is registered with them. + struct SessionKeyTerms: Equatable { + let budgetCredits: UInt64 + let lifetime: TimeInterval + + static let current = SessionKeyTerms( + budgetCredits: sessionKeyBudgetCredits, + lifetime: sessionKeyLifetime) + + func expiresAt(from now: Date) -> UInt64 { + UInt64((now.addingTimeInterval(lifetime).timeIntervalSince1970 * 1000).rounded()) + } + } + + static func isAvailable(protocolVersion: UInt32) -> Bool { + protocolVersion >= minimumProtocolVersion + } + + /// Which login an approval would run, as far as the wallet could tell + /// when it built the approval sheet. + enum Login: Equatable { + /// The network did not say which login it takes, so what approving + /// would give the app is unknown and there is nothing to approve. + case unresolved + /// Registers no key at approval. + case twoQR + /// Registers a session key with these terms. + case oneQR(SessionKeyTerms) + + var sessionKeyTerms: SessionKeyTerms? { + if case .oneQR(let terms) = self { return terms } + return nil + } + } + + /// The login a network at `protocolVersion` gets. + static func login(protocolVersion: UInt32) -> Login { + isAvailable(protocolVersion: protocolVersion) ? .oneQR(.current) : .twoQR + } + + /// The DIP-13 leaf of the session key: `hash256` of the app's ephemeral + /// public key. Not the lookup key of the response, which is `hash160` of + /// the same bytes. + static func requestId(appEphemeralPubKey: Data) -> Data { + Data(SHA256.hash(data: Data(SHA256.hash(data: appEphemeralPubKey)))) + } + + /// What the identity already holds for a derived session key. + enum SessionKeyState: Equatable { + /// Not on the identity: register it. + case absent + /// Registered, able to sign, and shown to be this app's key within + /// the approved terms: publish without an identity update. + case usable(keyId: UInt32) + /// Registered but disabled or expired, or not shown to be this app's + /// key within the approved terms. Platform never re-adds a key, so + /// this request cannot be answered; the app has to ask again. + case unusable + } + + /// How far the device clock may have moved back since a key was + /// registered before that key's expiry counts as later than the terms + /// allow. Without it a clock correction between two tries of one request + /// would turn the wallet's own key away. + static let clockTolerance: TimeInterval = 60 * 60 + + /// The session key is derived from the identity and the request's + /// ephemeral key, not from the app's contract, so a request that repeats + /// another app's ephemeral key derives that app's key. A registered key + /// is therefore reused only when it is bound to `appContractId` and + /// carries limits no wider than `terms`. + /// + /// The fetched key list carries no bounds. The bound is read from + /// `bindings`; a key the store cannot place counts as this app's only + /// when `grantedKeyIds` holds it — the keys this wallet recorded giving + /// this app for this request. + static func sessionKeyState( + publicKeyHash160: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo], + appContractId: Data, + bindings: [KeyBinding], + grantedKeyIds: Set, + terms: SessionKeyTerms, + now: Date + ) -> SessionKeyState { + guard let key = currentIdentityPublicKeys.first(where: { + $0.keyType == .ecdsaHash160 && $0.data == publicKeyHash160 + }) else { + return .absent + } + let nowMilliseconds = Int64((now.timeIntervalSince1970 * 1000).rounded()) + guard key.purpose == .authentication, + key.securityLevel == .high, + key.disabledAt == nil, + let totalBudget = key.totalBudget, + totalBudget <= terms.budgetCredits, + let expiresAt = key.expiresAt, + expiresAt > nowMilliseconds, + UInt64(expiresAt) <= terms.expiresAt(from: now.addingTimeInterval(clockTolerance)) else { + return .unusable + } + let keyId = UInt32(bitPattern: key.keyId) + let stored = bindings.first { $0.keyId == keyId && $0.data == key.data && !$0.hasUnreadableBound } + guard stored.map({ $0.singleContractId == appContractId }) ?? grantedKeyIds.contains(keyId) else { + return .unusable + } + return .usable(keyId: keyId) + } + + /// The id for a new key: one above the highest on the identity, disabled + /// keys included. `nil` when that id is the last one a key can have. + static func nextKeyId( + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> UInt32? { + let highest = currentIdentityPublicKeys.map { UInt32(bitPattern: $0.keyId) }.max() ?? 0 + return highest == .max ? nil : highest + 1 + } + + /// The key an app is given: it can only sign for the contract the request + /// named, spend up to `totalBudget`, and stops working at `expiresAt`. + static func sessionIdentityPubkey( + keyId: UInt32, + publicKeyHash160: Data, + appContractId: Data, + totalBudget: UInt64, + expiresAt: UInt64 + ) -> ManagedPlatformWallet.IdentityPubkey { + ManagedPlatformWallet.IdentityPubkey( + keyId: keyId, + keyType: .ecdsaHash160, + purpose: .authentication, + securityLevel: .high, + pubkeyBytes: publicKeyHash160, + contractBounds: .singleContract(id: appContractId), + totalBudget: totalBudget, + expiresAt: expiresAt + ) + } + + /// What the wallet's store of identity keys says about one key: its + /// payload, whether it is disabled there, and the contract it is bound to + /// when the bound is a whole single contract. The fetched key list + /// carries no bounds, so for a key without a grant record this is where + /// its app is read from. + struct KeyBinding: Equatable { + let keyId: UInt32 + let data: Data + let singleContractId: Data? + var isDisabled = false + /// The stored key has the type, purpose, level and limits of a key + /// this login registers. + var isSessionKeyShaped = false + /// The store has a bound for the key that this build cannot read, so + /// the key's app is unknown rather than "another one". + var hasUnreadableBound = false + /// The stored key has the type, purpose and level of the two-QR + /// login's authentication key, or of its encryption key. + var isTwoQRAuthenticationKeyShaped = false + var isTwoQREncryptionKeyShaped = false + } + + /// Whether the store shows either key of the two-QR login, not disabled, + /// in the kind that login registers it. A two-QR login leaves no grant + /// record, so on an identity that is not the active one this is how its + /// keys are recognised. + static func holdsLiveTwoQRLoginKey( + authenticationPublicKeyHash160: Data, + encryptionPublicKey: Data, + bindings: [KeyBinding] + ) -> Bool { + bindings.contains { + !$0.isDisabled + && (($0.isTwoQRAuthenticationKeyShaped && $0.data == authenticationPublicKeyHash160) + || ($0.isTwoQREncryptionKeyShaped && $0.data == encryptionPublicKey)) + } + } + + /// Whether the store shows a live session key bound to `appContractId`. + static func holdsLiveSessionKey(appContractId: Data, bindings: [KeyBinding]) -> Bool { + bindings.contains { $0.singleContractId == appContractId && !$0.isDisabled && $0.isSessionKeyShaped } + } + + /// The most keys one identity update may disable (Platform consensus). + static let maxKeysDisabledPerUpdate = 10 + + /// Whether the store's bound for a key says which contract, if any, the + /// key is bound to: no bound (0), a 32-byte single contract (1), a + /// document type (2) or a contract group (3). + static func isReadableBound(boundsKind: Int, boundIds: [Data]?) -> Bool { + switch boundsKind { + case 0, 2, 3: return true + case 1: return boundIds?.first?.count == 32 + default: return false + } + } + + /// The contract a stored key is bound to as a whole. `boundsKind` is the + /// store's discriminant: 1 is a single contract; a document-type bound + /// (2), a contract group (3) and no bound (0) give nil. + static func singleContractId(boundsKind: Int, boundIds: [Data]?) -> Data? { + guard boundsKind == 1, let id = boundIds?.first, id.count == 32 else { return nil } + return id + } + + private static func isLiveSessionKey(_ key: ManagedIdentity.IdentityPublicKeyInfo) -> Bool { + key.keyType == .ecdsaHash160 + && key.purpose == .authentication + && key.securityLevel == .high + && key.disabledAt == nil + } + + /// The keys a disconnect has to disable: the live ones behind `grants`, + /// plus every live key of the same kind that `bindings` shows bound to + /// `appContractId` and that carries a budget and an expiry, as every key + /// this login registers does. That covers the keys a reinstalled wallet + /// has no grant record for. A key that is gone, already disabled, or of + /// another shape is left alone, including one another wallet gave the + /// app with a document-type bound, a single limit or another key type. + /// A key without a grant record whose payload is + /// `twoQRAuthenticationKeyHash160` is left alone as well: the two-QR + /// login's authentication key has the same kind and bound, and a + /// `dash-st:` registration may have put limits on it. The caller passes + /// it when that key is to stay. + static func sessionKeyIdsToDisable( + for grants: [AppConnectGrant], + appContractId: Data, + bindings: [KeyBinding], + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo], + twoQRAuthenticationKeyHash160: Data? = nil + ) -> [UInt32] { + let granted = Set(grants.map(\.sessionKeyId)) + return currentIdentityPublicKeys + .filter { key in + guard isLiveSessionKey(key) else { return false } + let keyId = UInt32(bitPattern: key.keyId) + if granted.contains(keyId) { return true } + guard key.totalBudget != nil, key.expiresAt != nil, + key.data != twoQRAuthenticationKeyHash160 else { return false } + return bindings.contains { + $0.keyId == keyId && $0.data == key.data && $0.singleContractId == appContractId + } + } + .map { UInt32(bitPattern: $0.keyId) } + .sorted() + } + + /// Live session keys whose bound contract is unknown: not behind + /// `grants`, and either absent from `bindings` or stored with a bound + /// that cannot be read. Any of them could belong to the app being + /// disconnected, so a disconnect must stop on these instead of reporting + /// every key as turned off. + static func sessionKeyIdsWithUnknownBinding( + for grants: [AppConnectGrant], + bindings: [KeyBinding], + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> [UInt32] { + let granted = Set(grants.map(\.sessionKeyId)) + return currentIdentityPublicKeys + .filter { key in + guard isLiveSessionKey(key) else { return false } + let keyId = UInt32(bitPattern: key.keyId) + return !granted.contains(keyId) && !bindings.contains { + $0.keyId == keyId && $0.data == key.data && !$0.hasUnreadableBound + } + } + .map { UInt32(bitPattern: $0.keyId) } + .sorted() + } + + /// Keys the store holds as live and bound to `appContractId` that the + /// given view of the identity does not contain. As with a missing granted + /// key, the view is out of date and a disconnect must stop. + static func boundKeyIdsMissing( + from currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo], + appContractId: Data, + bindings: [KeyBinding] + ) -> [UInt32] { + let known = Set(currentIdentityPublicKeys.map { UInt32(bitPattern: $0.keyId) }) + return bindings + .filter { $0.singleContractId == appContractId && !$0.isDisabled && !known.contains($0.keyId) } + .map(\.keyId) + .sorted() + } + + /// Granted session keys the given view of the identity does not contain. + /// Platform never removes a key, it only disables it, so a missing id + /// means the view is out of date — not that the key is gone. A disconnect + /// must stop on these instead of treating them as dealt with. + static func sessionKeyIdsMissing( + from currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo], + for grants: [AppConnectGrant] + ) -> [UInt32] { + let known = Set(currentIdentityPublicKeys.map { UInt32(bitPattern: $0.keyId) }) + return Set(grants.map(\.sessionKeyId)).subtracting(known).sorted() + } + + /// What a disconnect leaves of the matching records once their keys are + /// disabled. A record whose response is known to be gone is done; the + /// rest stay, marked, until their response is deleted, or found if its + /// publish never confirmed. + static func markKeysDisabled(in grants: inout [AppConnectGrant], where matches: (AppConnectGrant) -> Bool) { + grants.removeAll { matches($0) && $0.responseDeleted == true } + for index in grants.indices where matches(grants[index]) { + grants[index].keyDisabled = true + } + } + + /// The query for one identity's response to one request, in the order of + /// the contract's only index. A byte-array value travels as base64. + static func responseLookupWhereClause(appEphemeralPubKeyHash: Data, ownerIdentityId: Data) -> String { + """ + [["appEphemeralPubKeyHash","==","\(appEphemeralPubKeyHash.base64EncodedString())"],\ + ["$ownerId","==","\(ownerIdentityId.toBase58String())"]] + """ + } + + /// The values of a response read back from Platform, or `nil` when the + /// document is not the response to `appEphemeralPubKeyHash` or cannot be + /// read. A delete has to carry the values that are on Platform, and those + /// are this wallet's own only when it recorded them before publishing. + /// + /// The SDK returns a byte field as base64 text. An array of numbers and + /// hex text are read as well, so a change of that encoding does not turn + /// every lookup into a failure. + static func responseValues( + fromDocument document: [String: Any], + appEphemeralPubKeyHash: Data + ) -> ResponseValues? { + func field(_ name: String) -> Any? { + document[name] ?? (document["properties"] as? [String: Any])?[name] + } + func bytes(_ value: Any?, _ decode: (String) -> Data?) -> Data? { + if let text = value as? String { return decode(text) } + guard let numbers = value as? [Int], numbers.allSatisfy({ (0 ... 255).contains($0) }) else { return nil } + return Data(numbers.map(UInt8.init)) + } + let decoders: [(String) -> Data?] = [{ Data(base64Encoded: $0) }, { data(fromHex: $0) }] + for decode in decoders { + guard bytes(field("appEphemeralPubKeyHash"), decode) == appEphemeralPubKeyHash, + let walletEphemeralPubKey = bytes(field("walletEphemeralPubKey"), decode), + walletEphemeralPubKey.count == 33, + let encryptedPayload = bytes(field("encryptedPayload"), decode), + (60 ... 572).contains(encryptedPayload.count) else { continue } + return ResponseValues( + appEphemeralPubKeyHash: appEphemeralPubKeyHash.toHexString(), + walletEphemeralPubKey: walletEphemeralPubKey.toHexString(), + encryptedPayload: encryptedPayload.toHexString()) + } + return nil + } + + static func data(fromHex hex: String) -> Data? { + guard hex.count.isMultiple(of: 2) else { return nil } + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index ..< next], radix: 16) else { return nil } + data.append(byte) + index = next + } + return data + } + + /// The three values of a `loginKeyResponse`, as hex. + struct ResponseValues: Codable, Equatable { + let appEphemeralPubKeyHash: String + let walletEphemeralPubKey: String + let encryptedPayload: String + + init(appEphemeralPubKeyHash: String, walletEphemeralPubKey: String, encryptedPayload: String) { + self.appEphemeralPubKeyHash = appEphemeralPubKeyHash + self.walletEphemeralPubKey = walletEphemeralPubKey + self.encryptedPayload = encryptedPayload + } + + init(_ sealed: SealedKey) { + self.init( + appEphemeralPubKeyHash: sealed.appEphemeralPubKeyHash.toHexString(), + walletEphemeralPubKey: sealed.walletEphemeralPublicKey.toHexString(), + encryptedPayload: sealed.encryptedPayload.toHexString() + ) + } + + /// The document's properties in the form `createDocument` and + /// delete-by-values take. + func propertiesJSON() throws -> String { + let data = try JSONSerialization.data( + withJSONObject: [ + "appEphemeralPubKeyHash": appEphemeralPubKeyHash, + "walletEphemeralPubKey": walletEphemeralPubKey, + "encryptedPayload": encryptedPayload, + ], + options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + } + + /// A 32-byte key encrypted to an app's ephemeral key, with what the app + /// needs to find and open it. Shared by both logins: the two-QR response + /// carries the login key, the one-QR response the session private key. + struct SealedKey: Equatable { + let appEphemeralPubKeyHash: Data + let walletEphemeralPublicKey: Data + let encryptedPayload: Data + } + + /// Encrypts `key` to the app. `walletEphemeralPrivateKey` is wiped before + /// returning. + static func seal( + _ key: Data, + appEphemeralPubKey: Data, + walletEphemeralPrivateKey: inout Data, + encrypt: (Data, Data, Data) throws -> Data = { key, walletPriv, appPub in + try KeyExchangeCrypto.encryptLoginKey( + key, + walletEphemeralPriv: walletPriv, + appEphemeralPub: appPub + ) + } + ) throws -> SealedKey { + defer { PlatformDashConnectDataSource.zero(&walletEphemeralPrivateKey) } + + let appEphemeralPubKeyHash = try KeyExchangeCrypto.hash160(appEphemeralPubKey) + guard appEphemeralPubKeyHash.count == 20 else { + throw DashConnectPlatformError.invalidHash160 + } + let walletEphemeralPublicKey = try Secp256k1.compressedPublicKey(privateKey: walletEphemeralPrivateKey) + let encryptedPayload = try encrypt(key, walletEphemeralPrivateKey, appEphemeralPubKey) + + return SealedKey( + appEphemeralPubKeyHash: appEphemeralPubKeyHash, + walletEphemeralPublicKey: walletEphemeralPublicKey, + encryptedPayload: encryptedPayload + ) + } +} + +/// A session key this wallet registered for an app, and the response that +/// carried it. Kept until the key is disabled and the response is gone. +/// +/// The response type is index-only on Platform: there is no lookup by app and +/// a delete has to carry the original values, so this record is the only way +/// back to the entry. It also names the key; without it a disconnect finds +/// the key by its contract bound, and the entry not at all. +struct AppConnectGrant: Codable, Equatable { + /// Base58 ids. + let identityId: String + let appContractId: String + /// `nil` until Platform confirmed the response: the grant is recorded + /// before publishing, so a publish whose outcome is unknown still leaves + /// a key that can be disabled. + var documentId: String? + /// Replaced only by values read back from Platform. + var values: AppConnect.ResponseValues + let sessionKeyId: UInt32 + /// The limits the key was registered with; `nil` for one without. + let totalBudget: UInt64? + let expiresAt: UInt64? + let publishedAt: Date + /// Set once the response is known to be gone from Platform while the key + /// stays live: deleted by a later login to the same app, or never there + /// after a publish that did not land. Absent means not known to be gone. + var responseDeleted: Bool? + /// Set once a disconnect disabled the key while its response could not + /// be deleted, or could not be shown to be absent; the record then only + /// waits for that. + var keyDisabled: Bool? + + /// What makes two records the same grant: one session key given for one + /// request. Records are matched on this, never on whole-value equality — + /// `publishedAt` does not survive the store's millisecond round trip bit + /// for bit. + var key: String { + [identityId, appContractId, String(sessionKeyId), values.appEphemeralPubKeyHash].joined(separator: "|") + } + + /// Call before a publish is submitted. Until Platform confirms it, the + /// response may be there or not, whatever the record said before: a + /// response an earlier login deleted is published again when its request + /// comes back. + mutating func markPublishStarted() { + documentId = nil + responseDeleted = nil + } + + /// Whether there is a response on Platform this record can still delete. + var hasDeletableResponse: Bool { + documentId != nil && responseDeleted != true + } + + /// A publish was started and never confirmed: the response may be on + /// Platform or not. + var hasUnconfirmedResponse: Bool { + documentId == nil && responseDeleted != true + } +} + +/// Lets one DashConnect operation at a time run for a wallet, across every +/// data source in the process. A screen that is opened again gets a new data +/// source over the same stores while an approval started on the old one is +/// still running; without this a disconnect could pick the keys to disable +/// before that approval registers one more. +/// +/// Callers wait their turn in the order they arrived. +actor DashConnectOperationGate { + static let shared = DashConnectOperationGate() + + private var held: Set = [] + private var waiting: [String: [CheckedContinuation]] = [:] + + /// `onWait` is called when the operation has to wait for its turn. + func run( + for key: String, + onWait: () -> Void = {}, + _ operation: () async throws -> T + ) async rethrows -> T { + if held.contains(key) { onWait() } + await enter(key) + do { + let result = try await operation() + leave(key) + return result + } catch { + leave(key) + throw error + } + } + + private func enter(_ key: String) async { + guard held.contains(key) else { + held.insert(key) + return + } + // The turn is handed over by `leave`, which keeps `key` held. + await withCheckedContinuation { waiting[key, default: []].append($0) } + } + + private func leave(_ key: String) { + guard var queue = waiting[key], !queue.isEmpty else { + waiting[key] = nil + held.remove(key) + return + } + let next = queue.removeFirst() + waiting[key] = queue + next.resume() + } +} + +protocol AppConnectResponseStore { + /// Whether `save` keeps anything. A login must not register a key the + /// store cannot record: its response could not be found again. + var canPersist: Bool { get } + func load() -> [AppConnectGrant] + func save(_ grants: [AppConnectGrant]) + /// This store fixed to one wallet, and to the network scope it has now, + /// like `DashConnectStore.pinned(toWalletIdHex:)`. + func pinned(toWalletIdHex walletIdHex: String) -> any AppConnectResponseStore +} + +extension AppConnectResponseStore { + var canPersist: Bool { true } +} + +/// Wallet- and network-scoped, like `UserDefaultsDashConnectStore`, whose +/// scope it borrows. Nothing stored is secret: the values are public on +/// chain and the key ids are visible on the identity. +final class UserDefaultsAppConnectResponseStore: AppConnectResponseStore { + private let defaults: UserDefaults + private let scope: Scope + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "dashconnect.app-connect-store") + + /// Where the scope comes from. + enum Scope { + /// Borrowed from a connection store, so both keep one wallet's data. + case connections(UserDefaultsDashConnectStore) + /// Handed in as it is; it names no wallet. + case key(() -> String?) + } + + convenience init( + defaults: UserDefaults = .standard, + network: DashConnectNetwork, + scopeKeyProvider: (() -> String?)? = nil + ) { + self.init( + defaults: defaults, + scope: scopeKeyProvider.map(Scope.key) + ?? .connections(UserDefaultsDashConnectStore(defaults: defaults, network: network))) + } + + init(defaults: UserDefaults, scope: Scope) { + self.defaults = defaults + self.scope = scope + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + self.decoder = decoder + } + + private var storageKey: String? { + let scopeKey: String? + switch scope { + case .connections(let connections): scopeKey = connections.storageKey + case .key(let provider): scopeKey = provider() + } + return scopeKey.map { "\($0).app-connect-responses.v1" } + } + + func pinned(toWalletIdHex walletIdHex: String) -> any AppConnectResponseStore { + switch scope { + case .connections(let connections): + return UserDefaultsAppConnectResponseStore( + defaults: defaults, scope: .connections(connections.pinnedStore(toWalletIdHex: walletIdHex))) + case .key(let provider): + // A handed-in scope names no wallet to replace: it is fixed to + // the value it has now. + let scopeKey = provider() + return UserDefaultsAppConnectResponseStore(defaults: defaults, scope: .key { scopeKey }) + } + } + + /// False as well while the stored value is not a list of rows: `save` + /// leaves such a value alone, so nothing new would be kept. + var canPersist: Bool { storageKey != nil && !readRows().isNotRowList } + + func load() -> [AppConnectGrant] { + readRows().grants + } + + func save(_ grants: [AppConnectGrant]) { + guard let storageKey else { return } + let stored = readRows() + // A value that is not a list of rows is not this build's to replace: + // nothing is written until it is gone. + guard !stored.isNotRowList else { + Self.logger.error("AppConnectResponseStore: stored value is not a JSON array; grants not saved") + return + } + // Rows this build cannot read are written back untouched: each one is + // the only record of a response on Platform, and dropping it would + // leave that response impossible to find. + let unreadable = stored.unreadable + guard !grants.isEmpty || !unreadable.isEmpty else { + defaults.removeObject(forKey: storageKey) + return + } + do { + let encoded = try grants.map { try JSONSerialization.jsonObject(with: encoder.encode($0)) } + defaults.set( + try JSONSerialization.data(withJSONObject: encoded + unreadable), + forKey: storageKey) + } catch { + Self.logger.error( + "AppConnectResponseStore: failed to encode grants: \(error.localizedDescription, privacy: .public)") + } + } + + /// Decodes row by row, like `UserDefaultsDashConnectStore`, so one + /// unreadable row costs one record instead of all of them. + private func readRows() -> (grants: [AppConnectGrant], unreadable: [Any], isNotRowList: Bool) { + guard let storageKey, let stored = defaults.object(forKey: storageKey) else { return ([], [], false) } + // A value of another kind is as much not this build's to replace as + // data that is not a list. + guard let data = stored as? Data, + let rows = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + Self.logger.error("AppConnectResponseStore: stored value is not a JSON array; left in place") + return ([], [], true) + } + var grants: [AppConnectGrant] = [] + var unreadable: [Any] = [] + for row in rows { + if JSONSerialization.isValidJSONObject(row), + let rowData = try? JSONSerialization.data(withJSONObject: row), + let grant = try? decoder.decode(AppConnectGrant.self, from: rowData) { + grants.append(grant) + } else { + unreadable.append(row) + } + } + if !unreadable.isEmpty { + Self.logger.warning( + "AppConnectResponseStore: \(unreadable.count, privacy: .public) stored grant(s) could not be read; kept as stored") + } + return (grants, unreadable, false) + } +} diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift index 41ea7e5521..06083fed35 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -25,7 +25,10 @@ protocol DashConnectDataSource { func parseQR(_ content: String) async throws -> DashConnectQr func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest - func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection + /// `consent` is the request as the approval sheet showed it. A login + /// that would give the app something else, or act for another identity, + /// wallet or network than it was resolved for, is refused. + func approveLogin(_ request: DashKeyRequest, consent: ConnectionRequest) async throws -> DAppConnection /// Parses a scanned `dash-st:` payload and either completes key /// registration immediately or returns a token purchase that awaits /// explicit user approval via `approveTokenPurchase(_:)`. @@ -36,8 +39,21 @@ protocol DashConnectDataSource { /// transition could already have reached Platform — the caller must not /// offer a retry when it could have. func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws - func disconnect(id: String) async - func remove(id: String) async + /// Turns a connection off. For a one-QR connection this disables the key + /// the app was given on the identity and deletes the published response, + /// and throws when the key could not be disabled — the connection then + /// stays as it was. The connection also stays, and that is thrown, while + /// the app holds a key on another identity of the wallet. For a two-QR + /// connection it only changes the local status: that login derives the + /// same key every time and Platform never re-adds a disabled key, so + /// disabling it would lock the app out for good. Turning off a one-QR + /// connection that replaced a two-QR one does disable those keys too. + /// + /// `scope` is the `DAppConnection.scope` of the row that was picked: + /// both calls act only while it still names the wallet they would act + /// on. + func disconnect(id: String, scope: String?) async throws + func remove(id: String, scope: String?) async } /// Why a token purchase failed, and — the part that decides what the UI may @@ -118,7 +134,11 @@ final class MockDashConnectDataSource: DashConnectDataSource { network: .testnet ) static let sampleLoginQRCode = DashConnectSampleQR.keyUri(for: sampleLoginRequest) - static let sampleRequest = ConnectionRequest(loginRequest: sampleLoginRequest) + static let sampleRequest: ConnectionRequest = { + var request = ConnectionRequest(loginRequest: sampleLoginRequest) + request.login = .twoQR + return request + }() static func sample(_ status: ConnectionStatus) -> DAppConnection { DAppConnection( @@ -194,7 +214,7 @@ final class MockDashConnectDataSource: DashConnectDataSource { throw DashConnectMockError.notDashConnectQrCode } - func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { + func approveLogin(_ request: DashKeyRequest, consent: ConnectionRequest) async throws -> DAppConnection { try await Task.sleep(nanoseconds: 400_000_000) if shouldFailNextApprove { @@ -225,7 +245,8 @@ final class MockDashConnectDataSource: DashConnectDataSource { appUrl: fallback.appUrl, walletUsername: nil, walletIdentityId: nil, - existingConnection: existingConnection(for: fallback.appContractId) + existingConnection: existingConnection(for: fallback.appContractId), + login: .twoQR ) } @@ -239,7 +260,7 @@ final class MockDashConnectDataSource: DashConnectDataSource { DashConnectMockError.stateTransitionNotSupported) } - func disconnect(id: String) async { + func disconnect(id: String, scope: String?) async throws { let disconnectedAt = Date() persistAndSend(subject.value.map { connection in guard connection.id == id else { return connection } @@ -253,7 +274,7 @@ final class MockDashConnectDataSource: DashConnectDataSource { }) } - func remove(id: String) async { + func remove(id: String, scope: String?) async { persistAndSend(subject.value.filter { $0.id != id }) } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift index 0d1e80d4a1..9853e0d85d 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -150,7 +150,11 @@ extension DashConnectTokenPurchaseRequest { /// the user locally turned the connection off from the wallet UI. /// /// `active` means the app's login keys are registered on the identity and the -/// wallet has already published the matching `loginKeyResponse`. +/// wallet has already published the matching `loginKeyResponse`. For a one-QR +/// connection it means the app holds a session key, registered on an +/// identity of this wallet, that this wallet has not disabled: the row is +/// made as soon as the key is registered, whether or not its response was +/// published. enum ConnectionStatus: String, Codable, CaseIterable { case approved case active @@ -173,6 +177,14 @@ enum ConnectionStatus: String, Codable, CaseIterable { } } +/// Which login made a connection. It decides what turning it off means: +/// a one-QR connection has a session key on the identity to disable, a +/// two-QR connection does not (see `DashConnectDataSource.disconnect`). +enum DashConnectLoginKind: String, Codable { + case twoQR + case oneQR +} + /// A connected app identified by its stable contract id. struct DAppConnection: Identifiable, Equatable, Hashable { let id: String @@ -180,6 +192,12 @@ struct DAppConnection: Identifiable, Equatable, Hashable { let url: String let status: ConnectionStatus let updatedAt: Date + var loginKind: DashConnectLoginKind = .twoQR + /// Names the wallet and network a data source published this row for; + /// `nil` on a row that was built or read from a store. Not stored. It + /// goes back with a disconnect or a remove, so a row picked from a list + /// that has since changed wallets is not acted on in the new one. + var scope: String? = nil } /// A pending request derived from a scanned login QR code. @@ -190,6 +208,14 @@ struct ConnectionRequest: Equatable { let walletUsername: String? let walletIdentityId: String? let existingConnection: DAppConnection? + /// The login approving this request would run. + var login: AppConnect.Login = .unresolved + /// Why the login is unresolved, when the wallet knows. + var unresolvedLoginReason: String? = nil + /// Names the wallet and network this request was resolved for; `nil` + /// when it was not resolved against one. An approval runs only while it + /// is still the active one. + var scope: String? = nil } extension ConnectionRequest { @@ -217,7 +243,10 @@ extension ConnectionRequest { appUrl: String, walletUsername: String?, walletIdentityId: String?, - existingConnection: DAppConnection? + existingConnection: DAppConnection?, + login: AppConnect.Login = .unresolved, + unresolvedLoginReason: String? = nil, + scope: String? = nil ) { self.init( appLabel: appLabel.trimmingCharacters(in: .whitespacesAndNewlines), @@ -225,7 +254,28 @@ extension ConnectionRequest { appContractId: loginRequest.contractId.toBase58String(), walletUsername: walletUsername, walletIdentityId: walletIdentityId, - existingConnection: existingConnection + existingConnection: existingConnection, + login: login, + unresolvedLoginReason: unresolvedLoginReason, + scope: scope ) } + + /// Whether this request, as a sheet showed it, was resolved for the + /// identity, wallet and network an approval is about to run for. + func wasResolved(forIdentityId identityId: String, scope: String) -> Bool { + walletIdentityId == identityId && self.scope == scope + } +} + +extension AppConnect.SessionKeyTerms { + /// The budget as DASH at full credit precision, e.g. "0.05 DASH". + var budgetText: String { + PlatformCreditsFormatter.dashString(budgetCredits) + } + + /// The lifetime in whole days. + var lifetimeDays: Int { + Int(lifetime / (24 * 60 * 60)) + } } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift b/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift index faa99db9a1..0bd2b0cc1a 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift @@ -23,6 +23,13 @@ import os protocol DashConnectStore { func load() -> [DAppConnection] func save(_ connections: [DAppConnection]) + /// Names the wallet and network whose rows this store reads and writes + /// right now; `nil` when it has no scope. + var scopeKey: String? { get } + /// This store fixed to one wallet, and to the network scope it has now. + /// An operation that outlasts a wallet switch reads and writes through + /// it, so its rows stay with the wallet it ran for. + func pinned(toWalletIdHex walletIdHex: String) -> any DashConnectStore } final class UserDefaultsDashConnectStore: DashConnectStore { @@ -76,6 +83,22 @@ final class UserDefaultsDashConnectStore: DashConnectStore { return "dashconnect.connections.v1.\(networkScope).\(walletScope)" } + var scopeKey: String? { storageKey } + + func pinned(toWalletIdHex walletIdHex: String) -> any DashConnectStore { + pinnedStore(toWalletIdHex: walletIdHex) + } + + func pinnedStore(toWalletIdHex walletIdHex: String) -> UserDefaultsDashConnectStore { + let devnetName = devnetNameProvider() + return UserDefaultsDashConnectStore( + defaults: defaults, + network: network, + walletIdHexProvider: { walletIdHex }, + devnetNameProvider: { devnetName } + ) + } + private var networkScope: String? { guard network == .devnet else { return network.rawValue } guard let name = devnetNameProvider()? @@ -115,7 +138,8 @@ final class UserDefaultsDashConnectStore: DashConnectStore { name: row.label, url: row.url, status: row.status, - updatedAt: row.updatedAt + updatedAt: row.updatedAt, + loginKind: row.loginKind ?? .twoQR ) ) } @@ -140,7 +164,8 @@ final class UserDefaultsDashConnectStore: DashConnectStore { label: $0.name, url: $0.url, status: $0.status, - updatedAt: $0.updatedAt + updatedAt: $0.updatedAt, + loginKind: $0.loginKind == .twoQR ? nil : $0.loginKind ) } @@ -166,6 +191,9 @@ final class UserDefaultsDashConnectStore: DashConnectStore { let url: String let status: ConnectionStatus let updatedAt: Date + /// Absent for a two-QR connection, which is every row written before + /// the one-QR login existed. + let loginKind: DashConnectLoginKind? } private static func base58Decode(_ string: String) -> Data? { diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift index 54b48d4883..799637733d 100644 --- a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -70,6 +70,17 @@ enum DashConnectPlatformError: LocalizedError, Equatable { case keyRegistrationMismatchedDerivedKey(KeyPurpose) case tokenPurchaseWrongIdentity case tokenPurchaseTokenIdMismatch + case unsupportedStateTransition(String) + case sessionKeyUnusable + case identityKeysOutOfDate + case grantOnAnotherIdentity + case grantStoreUnavailable + case approvalOutOfDate + case loginUnresolved + case grantNotRecorded + case noFreeKeyId + case otherIdentitiesNotChecked + case walletChanged case ephemeralKeyGenerationFailed case ambiguousKeyRegistrationConnection case devnetLoginContractNotConfigured @@ -117,6 +128,48 @@ enum DashConnectPlatformError: LocalizedError, Equatable { return "The scanned token purchase targets a different identity." case .tokenPurchaseTokenIdMismatch: return "The scanned token purchase names a token that does not belong to the contract and position it would buy from." + case .identityKeysOutOfDate: + return NSLocalizedString( + "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later.", + comment: "DashConnect") + case .grantOnAnotherIdentity: + return NSLocalizedString( + "This app has a key on another identity of this wallet. Switch to that identity to turn it off.", + comment: "DashConnect") + case .grantStoreUnavailable: + return NSLocalizedString( + "This wallet cannot record the key it would give the app, so the sign-in was not started.", + comment: "DashConnect") + case .grantNotRecorded: + return NSLocalizedString( + "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key.", + comment: "DashConnect") + case .otherIdentitiesNotChecked: + return NSLocalizedString( + "The app's keys on this identity are off, but this wallet could not check its other identities. Try again.", + comment: "DashConnect") + case .walletChanged: + return NSLocalizedString("The active wallet or identity changed. Try again.", comment: "DashConnect") + case .noFreeKeyId: + return NSLocalizedString("This identity cannot take another key.", comment: "DashConnect") + case .approvalOutOfDate: + return NSLocalizedString( + "The details of this sign-in changed while it was on screen. Check them and approve again.", + comment: "DashConnect") + case .loginUnresolved: + return NSLocalizedString( + "This wallet could not check what this sign-in would give the app.", + comment: "DashConnect") + case .sessionKeyUnusable: + return NSLocalizedString( + "This sign-in request can no longer be approved. Ask the app for a new code.", + comment: "DashConnect") + case .unsupportedStateTransition(let kindName): + return String( + format: NSLocalizedString( + "This wallet cannot approve the scanned request (%@).", + comment: "DashConnect: the placeholder is the kind of the scanned state transition"), + kindName) case .devnetLoginContractNotConfigured: return NSLocalizedString( "The devnet DashConnect contract id is not set or is not a valid identifier. Enter it in Settings → Devnet Settings.", @@ -164,6 +217,10 @@ struct DashConnectKeyRegistrationKey: Equatable { let securityLevel: SecurityLevel let publicKeyData: Data let contractBounds: ContractBounds? + /// Protocol 14 limits the scanned transition put on the key. Carried so + /// the rebuilt registration grants exactly what was asked for. + var totalBudget: UInt64? = nil + var expiresAt: UInt64? = nil } struct DashConnectKeyRegistrationTransition: Equatable { @@ -230,7 +287,8 @@ struct PlatformWalletDashConnectStateTransitionParser: DashConnectStateTransitio } func parse(_ transitionBytes: Data) throws -> DashConnectParsedStateTransition { - switch try parseTransition(transitionBytes) { + let transition = try parseTransition(transitionBytes) + switch transition.kind { case .identityUpdate(let parsed): return .keyRegistration( DashConnectKeyRegistrationTransition( @@ -264,25 +322,48 @@ struct PlatformWalletDashConnectStateTransitionParser: DashConnectStateTransitio // this flow derives, so refuse the transition instead. throw DashConnectPlatformError.keyRegistrationUnexpectedMutation } - } + }, + totalBudget: key.totalBudget, + expiresAt: key.expiresAt ) }, disablePublicKeyIds: parsed.disablePublicKeyIds ) ) - case .tokenPurchase(let parsed): + case .batch(let batch): + // The SDK now describes every batch, where it used to refuse all + // but a lone token direct purchase. The approval sheet and the + // rebuild in `approveTokenPurchase` still cover exactly that one + // case, so everything else is refused here: more than one row + // would be signed without being shown, and an incomplete summary + // (a note, group-action info) carries fields the rebuild drops. + guard transition.complete, + batch.transitions.count == 1, + case .token(let row) = batch.transitions[0], + row.action == Self.tokenDirectPurchaseAction, + row.complete, + let tokenCount = row.tokenCount, + let totalAgreedPrice = row.amount else { + throw DashConnectPlatformError.unsupportedStateTransition(transition.kindName) + } return .tokenPurchase( DashConnectTokenPurchaseTransition( - ownerId: parsed.ownerId, - dataContractId: parsed.dataContractId, - tokenId: parsed.tokenId, - tokenContractPosition: parsed.tokenContractPosition, - tokenCount: parsed.tokenCount, - totalAgreedPrice: parsed.totalAgreedPrice + ownerId: batch.ownerId, + dataContractId: row.dataContractId, + tokenId: row.tokenId, + tokenContractPosition: row.tokenContractPosition, + tokenCount: tokenCount, + totalAgreedPrice: totalAgreedPrice ) ) + case .creditTransfer, .dataContractCreate, .dataContractUpdate, .other: + throw DashConnectPlatformError.unsupportedStateTransition(transition.kindName) } } + + /// rs-dpp's action name for a token direct purchase, as the SDK reports + /// it on a batch row. + private static let tokenDirectPurchaseAction = "DirectPurchase" } final class PlatformDashConnectDataSource: DashConnectDataSource { @@ -302,6 +383,59 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { let identityId: Data let identityIndex: UInt32 let storedUsername: String? + /// One pair for the whole operation: a context resolved again after + /// a PIN prompt takes over the pair of the one it replaces. + var stores: Stores + } + + /// The connection rows and grant records of one wallet. A context carries + /// the pair fixed to its own wallet: the stores follow the active wallet, + /// and an operation that waits on the network or a PIN prompt must still + /// write to the wallet whose identity it changed. + private struct Stores { + let walletIdHex: String + let connections: any DashConnectStore + let grants: any AppConnectResponseStore + /// Where the network is reached, as configured when the pair was + /// made; `nil` on a network with one fixed set of nodes. + let endpoint: String? + + /// Names what an operation runs for: the wallet, the network scope + /// the connection store was fixed to (which tells two devnets of one + /// wallet apart by name), and the endpoint, so a devnet pointed at + /// other nodes under the same name is another scope. The stores + /// themselves are keyed by wallet and name only. + var scope: String { "\(walletIdHex) \(connections.scopeKey ?? "") \(endpoint ?? "")" } + + /// The rows as they are published: each names this scope. + func published(_ rows: [DAppConnection]) -> [DAppConnection] { + Self.rows(rows, scope: scope) + } + + /// The rows as they are stored: without the scope a published row + /// carries. + static func stored(_ rows: [DAppConnection]) -> [DAppConnection] { + Self.rows(rows, scope: nil) + } + + private static func rows(_ rows: [DAppConnection], scope: String?) -> [DAppConnection] { + rows.map { row in + var row = row + row.scope = scope + return row + } + } + } + + /// The scope of the rows `subject` holds. Read and written on the main + /// actor together with every send, so the two cannot come apart. + @MainActor + private final class ShownRows { + var scope: String? + + nonisolated init(scope: String?) { + self.scope = scope + } } private static let logger = Logger( @@ -321,7 +455,13 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { private let supportedNetwork: DashConnectNetwork private let store: any DashConnectStore + private let responseStore: any AppConnectResponseStore private let subject: CurrentValueSubject<[DAppConnection], Never> + private let shownRows: ShownRows + private let operationGate: DashConnectOperationGate + private let activeWalletIdHex: () -> String? + private let networkEndpoint: () -> String? + private var walletChangeObserver: AnyCancellable? private let authorizer: DWIdentityAuthorizer private let stateTransitionParser: any DashConnectStateTransitionParsing private let now: () -> Date @@ -341,8 +481,13 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { init( supportedNetwork: DashConnectNetwork = PlatformDashConnectDataSource.currentEnvironmentNetwork(), store: (any DashConnectStore)? = nil, + responseStore: (any AppConnectResponseStore)? = nil, + activeWalletIdHex: @escaping () -> String? = { WalletEnvironment.activeWalletIdHex as String? }, authorizer: DWIdentityAuthorizer = DWIdentityAuthorizer(), stateTransitionParser: any DashConnectStateTransitionParsing = PlatformWalletDashConnectStateTransitionParser(), + networkEndpoint: (() -> String?)? = nil, + notificationCenter: NotificationCenter = .default, + operationGate: DashConnectOperationGate = .shared, now: @escaping () -> Date = Date.init ) { assert( @@ -350,10 +495,28 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { "DashConnect Platform publish runs on testnet and devnet only.") self.supportedNetwork = supportedNetwork self.store = store ?? UserDefaultsDashConnectStore(network: supportedNetwork) + self.responseStore = responseStore ?? UserDefaultsAppConnectResponseStore(network: supportedNetwork) self.authorizer = authorizer self.stateTransitionParser = stateTransitionParser self.now = now - self.subject = CurrentValueSubject(self.store.load()) + self.activeWalletIdHex = activeWalletIdHex + self.operationGate = operationGate + // Only a devnet's nodes are configurable. + let networkEndpoint = networkEndpoint + ?? (supportedNetwork == .devnet ? { DevnetConfiguration.quorumURL } : { nil }) + self.networkEndpoint = networkEndpoint + let active = Self.pinnedStores( + walletIdHex: activeWalletIdHex(), store: self.store, responseStore: self.responseStore, + endpoint: networkEndpoint()) + self.shownRows = ShownRows(scope: active?.scope) + self.subject = CurrentValueSubject(active.map { $0.published($0.connections.load()) } ?? []) + // The list is loaded once, and the active wallet can change while + // this data source lives. + self.walletChangeObserver = notificationCenter + .publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .sink { [weak self] _ in + Task { @MainActor in self?.showActiveWalletRows() } + } } var connections: AnyPublisher<[DAppConnection], Never> { @@ -380,21 +543,42 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest { let fallback = ConnectionRequest(loginRequest: loginRequest) - let existingConnection = existingConnection(for: fallback.appContractId) + await showActiveWalletRows() - guard let context = try? await requireContext() else { + let context: Context + do { + context = try await requireContext() + } catch { + let existingConnection = await shownRow(id: fallback.appContractId) return ConnectionRequest( loginRequest: loginRequest, appLabel: fallback.appLabel, appUrl: fallback.appUrl, walletUsername: nil, walletIdentityId: nil, - existingConnection: existingConnection + existingConnection: existingConnection, + unresolvedLoginReason: error.localizedDescription ) } + let existingConnection = rows(in: context.stores).first { $0.id == fallback.appContractId } + + // Which login this network gets is settled here, so the sheet can + // show what the app is given. With the version unknown the request + // says so, and the sheet offers nothing to approve. The lookup runs + // alongside the two below. + async let resolvedProtocolVersion = Self.networkProtocolVersion(sdk: context.sdk) let metadata = await resolveAppMetadata(for: loginRequest, sdk: context.sdk) let walletUsername = await resolveWalletUsername(context: context) + let protocolVersion: UInt32? + var unresolvedLoginReason: String? + do { + protocolVersion = try await resolvedProtocolVersion + } catch { + Self.logger.error("🔗 DASHCONNECT :: protocol version unknown — \(error.localizedDescription, privacy: .public)") + protocolVersion = nil + unresolvedLoginReason = error.localizedDescription + } return ConnectionRequest( loginRequest: loginRequest, @@ -402,27 +586,110 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { appUrl: metadata.url, walletUsername: walletUsername, walletIdentityId: context.identityId.toBase58String(), - existingConnection: existingConnection + existingConnection: existingConnection, + login: protocolVersion.map(AppConnect.login(protocolVersion:)) ?? .unresolved, + unresolvedLoginReason: unresolvedLoginReason, + scope: context.stores.scope ) } - func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { + /// One approval, key registration or disconnect at a time for the active + /// wallet, whichever data source it came through. The turn is for the + /// wallet that was active when it was asked for: if another one is + /// active when it comes, the operation does not start. + /// + /// An operation that already has a context names that context's wallet, + /// so its turn is that wallet's whichever one is active by now. + private func gated( + walletIdHex: String? = nil, + _ operation: () async throws -> T + ) async throws -> T { + let walletIdHex = walletIdHex ?? activeWalletIdHex() ?? "" + return try await operationGate.run( + for: walletIdHex, + onWait: { Self.logger.info("🔗 DASHCONNECT :: waiting for another DashConnect operation to finish") } + ) { + guard (activeWalletIdHex() ?? "") == walletIdHex else { + throw DashConnectPlatformError.walletChanged + } + return try await operation() + } + } + + func approveLogin(_ request: DashKeyRequest, consent: ConnectionRequest) async throws -> DAppConnection { + try await gated { try await approveLoginInTurn(request, consent: consent) } + } + + private func approveLoginInTurn( + _ request: DashKeyRequest, + consent: ConnectionRequest + ) async throws -> DAppConnection { Self.logger.info("🔗 DASHCONNECT :: approveLogin started") try validateNetwork(request.network) + await showActiveWalletRows() + guard consent.appContractId == request.contractId.toBase58String(), consent.login != .unresolved else { + throw DashConnectPlatformError.approvalOutOfDate + } - let context: Context + var context: Context do { context = try await requireContext() } catch { Self.logger.error("🔗 DASHCONNECT :: no context — \(error.localizedDescription, privacy: .public)") throw error } + // The sheet was resolved for one identity of one wallet on one + // network, and either login acts for that identity: it has to be + // the active one here, and again after the PIN prompt. The same + // wallet and identity under another devnet name is not what was + // shown. + guard consent.wasResolved( + forIdentityId: context.identityId.toBase58String(), scope: context.stores.scope) else { + Self.logger.error("🔗 DASHCONNECT :: the approved sign-in was resolved for another identity, wallet or network") + throw DashConnectPlatformError.approvalOutOfDate + } + // Which login this network can answer is decided before the PIN + // prompt: from protocol version 14 the response goes to the App + // Connect system contract and the wallet registers the key itself; + // below it that contract does not exist and the two-QR flow runs. + // A pinned SDK reports its pin (testnet is pinned to 13 in + // `SwiftDashSDKHost.platformVersion(for:)`), so testnet stays on the + // two-QR flow until that pin moves. + let protocolVersion: UInt32 + do { + protocolVersion = try await Self.networkProtocolVersion(sdk: context.sdk) + } catch { + // The wallet cannot tell any more whether the login the sheet + // offered is the one this network gets. Nothing was signed. + Self.logger.error("🔗 DASHCONNECT :: protocol version unknown — \(error.localizedDescription, privacy: .public)") + throw DashConnectPlatformError.loginUnresolved + } + // The sheet showed the login `makeConnectionRequest` resolved, with + // its terms for the one-QR login. If the network now gets the other + // one, what was approved is not what would run. + let login = AppConnect.login(protocolVersion: protocolVersion) + guard consent.login == login else { + Self.logger.error("🔗 DASHCONNECT :: the network now answers with the other login than the one approved") + throw DashConnectPlatformError.approvalOutOfDate + } do { try await authorize() } catch { Self.logger.error("🔗 DASHCONNECT :: authorization failed — \(error.localizedDescription, privacy: .public)") throw error } + // The PIN prompt can outlast a wallet or identity switch: sign with + // what is active now, and only if it is still the identity that was + // shown. + do { + context = try await contextAfterAuthorization(matching: context) + } catch DashConnectPlatformError.walletChanged { + throw DashConnectPlatformError.approvalOutOfDate + } + if let terms = login.sessionKeyTerms { + Self.logger.info("🔗 DASHCONNECT :: authorized; protocol \(protocolVersion, privacy: .public), one-QR login") + return try await approveAppConnectLogin(request, terms: terms, context: context) + } Self.logger.info("🔗 DASHCONNECT :: authorized; deriving login key") // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. @@ -510,14 +777,398 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { connection = Self.makeConnection(preview: preview, status: .approved, updatedAt: now()) } - var current = subject.value.filter { $0.id != connection.id } - current.append(connection) - persistAndSend(current) + await updateRows(in: context.stores) { rows in + rows.filter { $0.id != connection.id } + [connection] + } return connection } + /// `SDK.refreshProtocolVersion()` is a synchronous FFI call that makes a + /// proven network query. It runs on a plain dispatch queue so it does not + /// hold a cooperative-pool thread for a DAPI round trip. + private static func networkProtocolVersion(sdk: SDK) async throws -> UInt32 { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { try sdk.refreshProtocolVersion() }) + } + } + } + + /// The one-QR login: derive the request's session key, register it on the + /// identity with its bounds and limits, and only then publish the + /// encrypted key, so the app finds a live key on its first check. + /// + /// The grant and the connection row are recorded as soon as the key is + /// known to be on the identity, before the publish: whatever happens to + /// the publish, the key stays visible in Connections and can be disabled. + /// + /// Approving the same QR again finds the key registered and does not + /// register it twice; if the response is already recorded as published it + /// does nothing more. + private func approveAppConnectLogin( + _ request: DashKeyRequest, + terms: AppConnect.SessionKeyTerms, + context: Context + ) async throws -> DAppConnection { + guard let responseContractId = Self.decodeIdentifier(AppConnect.contractId) else { + throw DashConnectPlatformError.loginContractUnavailable + } + let stores = context.stores + guard stores.grants.canPersist else { + throw DashConnectPlatformError.grantStoreUnavailable + } + + let requestId = AppConnect.requestId(appEphemeralPubKey: request.appEphemeralPubKey) + // `deriveConnectKey` is main-actor isolated in the SDK. Only the two + // buffers leave the closure, so the private key below is the sole + // owner of its storage and the deferred wipe reaches the real bytes. + var (sessionPublicKey, sessionPrivateKey) = try await MainActor.run { + let key = try context.wallet.deriveConnectKey( + subFeature: .sessionAuthentication, + identityId: context.identityId, + leaf: requestId, + network: context.network + ) + return (key.publicKeyData, key.privateKeyData) + } + defer { Self.zero(&sessionPrivateKey) } + let sessionKeyHash160 = try KeyExchangeCrypto.hash160(sessionPublicKey) + + var walletEphemeralPrivateKey = try Self.generateEphemeralPrivateKey() + let sealed = try AppConnect.seal( + sessionPrivateKey, + appEphemeralPubKey: request.appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey + ) + + let identityId = context.identityId.toBase58String() + let appContractId = request.contractId.toBase58String() + let requestHash = sealed.appEphemeralPubKeyHash.toHexString() + let signer = KeychainSigner(modelContainer: context.modelContainer) + let currentPublicKeys = try await refreshedIdentityPublicKeys(context: context) + func isGrantForRequest(_ grant: AppConnectGrant) -> Bool { + grant.identityId == identityId && grant.appContractId == appContractId + && grant.values.appEphemeralPubKeyHash == requestHash + } + + // Read only for a key that is already on the identity. If the store + // cannot be read, such a key needs a grant record instead. + var bindings: [AppConnect.KeyBinding] = [] + var grantedKeyIds: Set = [] + if currentPublicKeys.contains(where: { $0.keyType == .ecdsaHash160 && $0.data == sessionKeyHash160 }) { + bindings = (try? Self.storedKeyBindings( + identityId: identityId, modelContainer: context.modelContainer)) ?? [] + grantedKeyIds = Set(stores.grants.load().filter(isGrantForRequest).map(\.sessionKeyId)) + } + + let sessionKeyId: UInt32 + var totalBudget: UInt64? + var expiresAt: UInt64? + switch AppConnect.sessionKeyState( + publicKeyHash160: sessionKeyHash160, + currentIdentityPublicKeys: currentPublicKeys, + appContractId: request.contractId, + bindings: bindings, + grantedKeyIds: grantedKeyIds, + terms: terms, + now: now() + ) { + case .unusable: + throw DashConnectPlatformError.sessionKeyUnusable + case .usable(let keyId): + Self.logger.info("🔗 DASHCONNECT :: session key already registered") + sessionKeyId = keyId + let registered = currentPublicKeys.first { UInt32(bitPattern: $0.keyId) == keyId } + totalBudget = registered?.totalBudget + expiresAt = registered?.expiresAt.map { UInt64(max(0, $0)) } + case .absent: + guard let keyId = AppConnect.nextKeyId(currentIdentityPublicKeys: currentPublicKeys) else { + throw DashConnectPlatformError.noFreeKeyId + } + let budget = terms.budgetCredits + let expiry = terms.expiresAt(from: now()) + let key = AppConnect.sessionIdentityPubkey( + keyId: keyId, + publicKeyHash160: sessionKeyHash160, + appContractId: request.contractId, + totalBudget: budget, + expiresAt: expiry + ) + Self.logger.info("🔗 DASHCONNECT :: registering session key") + try await signer.withAdditionalSigningKeys([ + (publicKey: sessionKeyHash160, privateKey: sessionPrivateKey), + ]) { + try await context.wallet.updateIdentity( + identityId: context.identityId, + addPublicKeys: [key], + signer: signer + ) + } + Self.logger.info("🔗 DASHCONNECT :: session key registered") + sessionKeyId = keyId + totalBudget = budget + expiresAt = expiry + } + + // From here the app-side key exists on the identity. Record it, and + // show the connection, before anything else can fail. A grant already + // recorded for this request keeps its values: if an earlier publish + // landed without confirming, those are the ones on Platform. + let grant = updateGrants(in: stores) { grants -> AppConnectGrant in + if let existing = grants.first(where: { isGrantForRequest($0) && $0.sessionKeyId == sessionKeyId }) { + return existing + } + let new = AppConnectGrant( + identityId: identityId, + appContractId: appContractId, + documentId: nil, + values: AppConnect.ResponseValues(sealed), + sessionKeyId: sessionKeyId, + totalBudget: totalBudget, + expiresAt: expiresAt, + publishedAt: now() + ) + grants.append(new) + return new + } + + let preview = await makeConnectionRequest(from: request) + let connection = Self.makeConnection( + preview: preview, status: .active, updatedAt: now(), loginKind: .oneQR) + await updateRows(in: stores) { rows in + rows.filter { $0.id != connection.id } + [connection] + } + + // The store was writable when the login started. If the record did + // not land all the same, a response published now could not be found + // again. The key is live, so the row above stays: a disconnect finds + // the key by its contract bound. + guard stores.grants.load().contains(where: { $0.key == grant.key }) else { + Self.logger.error("🔗 DASHCONNECT :: the session key was registered but its record could not be saved") + throw DashConnectPlatformError.grantNotRecorded + } + + // A response deleted by a later login to this app is published again + // when its own request comes back. + guard !grant.hasDeletableResponse else { + Self.logger.info("🔗 DASHCONNECT :: response already published for this request") + return connection + } + + // Besides this app's earlier responses, this takes whatever an earlier + // disconnect of any app left behind for this identity: its row is + // gone, so a login is the only moment left to finish that cleanup. + await deleteAppConnectResponses( + where: { + guard $0.identityId == identityId else { return false } + return $0.keyDisabled == true + || ($0.appContractId == appContractId && $0.values.appEphemeralPubKeyHash != requestHash) + }, + responseContractId: responseContractId, + context: context, + signer: signer + ) + + // Recorded before the publish is submitted: a response this record + // had as deleted may be back on Platform after a publish that fails + // to confirm, and a disconnect must then look for it. + updateGrants(in: stores) { grants in + guard let index = grants.firstIndex(where: { $0.key == grant.key }) else { return } + grants[index].markPublishStarted() + } + + Self.logger.info("🔗 DASHCONNECT :: publishing loginKeyResponse") + let documentId: Data + var publishedValues = grant.values + do { + (documentId, _) = try await context.wallet.createDocument( + ownerIdentityId: context.identityId, + contractId: responseContractId, + documentType: AppConnect.documentType, + propertiesJSON: try grant.values.propertiesJSON(), + signer: signer + ) + Self.logger.info("🔗 DASHCONNECT :: loginKeyResponse published") + } catch { + // A publish can land without confirming, and the response is + // unique per request and identity, so the retry is then refused. + // If the response is there, this login is complete; if it is not, + // or that cannot be told, the failure stands. + guard let published = try? await findAppConnectResponse( + ownerIdentityId: context.identityId, + appEphemeralPubKeyHash: sealed.appEphemeralPubKeyHash, + responseContractId: responseContractId, + sdk: context.sdk + ) else { + throw error + } + Self.logger.info("🔗 DASHCONNECT :: loginKeyResponse was already published") + documentId = published.documentId + publishedValues = published.values + } + + updateGrants(in: stores) { grants in + guard let index = grants.firstIndex(where: { $0.key == grant.key }) else { return } + grants[index].documentId = documentId.toBase58String() + grants[index].values = publishedValues + } + return connection + } + + /// This identity's response to one request, with the values it has on + /// Platform, or `nil` when there is none; a failed lookup or a response + /// that cannot be read throws. + private func findAppConnectResponse( + ownerIdentityId: Data, + appEphemeralPubKeyHash: Data, + responseContractId: Data, + sdk: SDK + ) async throws -> (documentId: Data, values: AppConnect.ResponseValues)? { + let response = try await sdk.documentList( + dataContractId: responseContractId.toBase58String(), + documentType: AppConnect.documentType, + whereClause: AppConnect.responseLookupWhereClause( + appEphemeralPubKeyHash: appEphemeralPubKeyHash, + ownerIdentityId: ownerIdentityId + ), + limit: 1 + ) + guard let documents = response["documents"] as? [[String: Any]] else { + throw DashConnectPlatformError.existingDocumentLookupFailed + } + guard let document = documents.first else { return nil } + guard let id = document["$id"] as? String, + let identifier = Self.decodeIdentifier(id), identifier.count == 32, + let values = AppConnect.responseValues( + fromDocument: document, appEphemeralPubKeyHash: appEphemeralPubKeyHash) else { + throw DashConnectPlatformError.existingDocumentLookupFailed + } + return (identifier, values) + } + + /// Reads, changes and writes the grant records in one step, from the + /// store each time. Every caller runs inside `gated`, so no other + /// operation of this wallet writes them in between. + @discardableResult + private func updateGrants(in stores: Stores, _ change: (inout [AppConnectGrant]) -> T) -> T { + var grants = stores.grants.load() + let result = change(&grants) + stores.grants.save(grants) + return result + } + + /// Deletes, by their original values, the responses of the matching + /// grants that are still on Platform. Best effort: a response that cannot + /// be deleted now stays recorded and is tried again by the next login + /// with this identity. It does not hold the caller back — the entry it leaves is + /// the encrypted key of a request the app has already finished with. + /// + /// A grant whose key is still live keeps its record, marked as having no + /// response left: a later disconnect picks the keys it has records for + /// without looking their app up in the wallet's key store. A grant whose + /// key is already disabled has no further use and is dropped. + private func deleteAppConnectResponses( + where matches: (AppConnectGrant) -> Bool, + responseContractId: Data, + context: Context, + signer: KeychainSigner + ) async { + await resolveUnconfirmedAppConnectResponses( + where: matches, responseContractId: responseContractId, context: context) + let pending = context.stores.grants.load().filter { matches($0) && $0.hasDeletableResponse } + for grant in pending { + do { + guard let documentIdText = grant.documentId, + let documentId = Self.decodeIdentifier(documentIdText) else { + throw DashConnectPlatformError.existingDocumentLookupFailed + } + let signingKeyId = try selectDocumentSigningKeyId( + wallet: context.wallet, + identityId: context.identityId + ) + _ = try await context.wallet.deleteDocument( + ownerIdentityId: context.identityId, + contractId: responseContractId, + documentType: AppConnect.documentType, + documentId: documentId, + propertiesJSON: try grant.values.propertiesJSON(), + signingKeyId: signingKeyId, + signer: signer + ) + markAppConnectResponseGone(grant, in: context.stores) + } catch { + Self.logger.error( + "🔗 DASHCONNECT :: previous loginKeyResponse not deleted — \(error.localizedDescription, privacy: .public)") + // A delete can land without confirming, and every retry is + // then refused because there is nothing left to delete. Only + // Platform can say which failure this was. + guard let hash = AppConnect.data(fromHex: grant.values.appEphemeralPubKeyHash) else { continue } + do { + let remaining = try await findAppConnectResponse( + ownerIdentityId: context.identityId, + appEphemeralPubKeyHash: hash, + responseContractId: responseContractId, + sdk: context.sdk + ) + if remaining == nil { markAppConnectResponseGone(grant, in: context.stores) } + } catch { + continue + } + } + } + } + + /// Records that a grant has no response on Platform any more. The record + /// of a live key stays for a disconnect to disable that key by; the + /// record of a disabled key has no further use. + private func markAppConnectResponseGone(_ grant: AppConnectGrant, in stores: Stores) { + updateGrants(in: stores) { grants in + guard let index = grants.firstIndex(where: { $0.key == grant.key }) else { return } + if grants[index].keyDisabled == true { + grants.remove(at: index) + } else { + grants[index].responseDeleted = true + } + } + } + + /// Settles the matching grants whose publish never confirmed, by asking + /// Platform. A response that is there gets its id and its values on + /// Platform, so it can be deleted; one that is not there needs nothing + /// more. A lookup that fails leaves the record as it is for the next try. + private func resolveUnconfirmedAppConnectResponses( + where matches: (AppConnectGrant) -> Bool, + responseContractId: Data, + context: Context + ) async { + for grant in context.stores.grants.load() where matches(grant) && grant.hasUnconfirmedResponse { + guard let hash = AppConnect.data(fromHex: grant.values.appEphemeralPubKeyHash) else { continue } + do { + let published = try await findAppConnectResponse( + ownerIdentityId: context.identityId, + appEphemeralPubKeyHash: hash, + responseContractId: responseContractId, + sdk: context.sdk + ) + guard let published else { + markAppConnectResponseGone(grant, in: context.stores) + continue + } + updateGrants(in: context.stores) { grants in + guard let index = grants.firstIndex(where: { $0.key == grant.key }) else { return } + grants[index].documentId = published.documentId.toBase58String() + grants[index].values = published.values + } + } catch { + Self.logger.error( + "🔗 DASHCONNECT :: unconfirmed loginKeyResponse not resolved — \(error.localizedDescription, privacy: .public)") + } + } + } + func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction { try validateNetwork(request.network) + await showActiveWalletRows() let context = try await requireContext() // The incoming bytes are parsed only to learn the intent — they are // never signed. Each branch validates the parsed values and rebuilds @@ -528,7 +1179,14 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { switch parsed { case .keyRegistration(let transition): - try await completeKeyRegistration(transition, context: context) + // Only this branch changes the identity and the rows. Its turn + // is the wallet of the context read above, and the context is + // resolved again once the turn comes: the wait can outlast a + // wallet, network or identity switch. + try await gated(walletIdHex: context.stores.walletIdHex) { + let current = try await contextAfterAuthorization(matching: context) + try await completeKeyRegistration(transition, context: current) + } return .keyRegistrationCompleted case .tokenPurchase(let purchase): return .tokenPurchaseApprovalRequired( @@ -550,28 +1208,16 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { // keys usually do, in their contract bounds. Picking the most // recently approved connection instead would derive app B's keys for // a QR scanned from app A. - let pendingConnection = try pendingApprovedConnectionForKeyRegistration( + let pendingConnection = try Self.pendingApprovedConnectionForKeyRegistration( + in: rows(in: context.stores), boundContractId: Self.boundAppContractId(in: transition) ) guard let pendingAppContractId = Self.decodeIdentifier(pendingConnection.id) else { throw DashConnectPlatformError.noApprovedConnectionAwaitingKeyRegistration } - // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. - var chainKey = try await MainActor.run { - try context.wallet.deriveIdentityAuthKeyAtSlot( - identityIndex: context.identityIndex, - keyId: UInt32(LoginKeyDerivation.defaultKeyIndex), - network: context.network - ).privateKeyData - } - defer { Self.zero(&chainKey) } - - var derivedMaterial = try Self.deriveKeyRegistrationMaterial( - chainKeyPrivateBytes: chainKey, - identityId: context.identityId, - appContractId: pendingAppContractId - ) + var derivedMaterial = try await deriveKeyRegistrationMaterial( + appContractId: pendingAppContractId, context: context) defer { Self.zero(&derivedMaterial.loginKey) Self.zero(&derivedMaterial.authenticationPrivateKey) @@ -594,24 +1240,15 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { ) guard !missingKeys.isEmpty else { - persistAndSend( - subject.value.map { connection in - guard connection.id == pendingConnection.id else { return connection } - return DAppConnection( - id: connection.id, - name: connection.name, - url: connection.url, - status: .active, - updatedAt: now() - ) - } - ) + await markActive(pendingConnection, in: context.stores) return } try await authorize() + // The keys were derived and checked for the identity above. + let authorized = try await contextAfterAuthorization(matching: context) - let signer = KeychainSigner(modelContainer: context.modelContainer) + let signer = KeychainSigner(modelContainer: authorized.modelContainer) try await signer.withAdditionalSigningKeys([ ( publicKey: validated.authenticationKey.publicKeyData, @@ -622,25 +1259,55 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { privateKey: derivedMaterial.encryptionPrivateKey ), ]) { - try await context.wallet.updateIdentity( - identityId: context.identityId, + try await authorized.wallet.updateIdentity( + identityId: authorized.identityId, addPublicKeys: missingKeys.map(Self.makeManagedIdentityPubkey), signer: signer ) } - persistAndSend( - subject.value.map { connection in + await markActive(pendingConnection, in: authorized.stores) + } + + /// The context to go on with after a wait — a PIN prompt, a turn at + /// the operation gate — which can outlast a wallet, network or identity + /// switch: what is active now, and only while it is still the identity, + /// the wallet and the network scope of `context`. + private func contextAfterAuthorization(matching context: Context) async throws -> Context { + let authorized = try await requireContext(pinnedTo: context.stores) + guard authorized.identityId == context.identityId else { + throw DashConnectPlatformError.walletChanged + } + return authorized + } + + /// What is active now, only while it is the wallet and the network + /// scope `stores` belongs to, and carrying that pair. The same wallet on + /// another devnet is refused: its identity and SDK are that devnet's, + /// and the records would go to this one's. + private func requireContext(pinnedTo stores: Stores) async throws -> Context { + var context = try await requireContext() + guard context.stores.scope == stores.scope else { + throw DashConnectPlatformError.walletChanged + } + context.stores = stores + return context + } + + private func markActive(_ pendingConnection: DAppConnection, in stores: Stores) async { + let activatedAt = now() + await updateRows(in: stores) { rows in + rows.map { connection in guard connection.id == pendingConnection.id else { return connection } return DAppConnection( id: connection.id, name: connection.name, url: connection.url, status: .active, - updatedAt: now() + updatedAt: activatedAt ) } - ) + } } /// Builds the user-facing approval request for a parsed token purchase, @@ -689,7 +1356,7 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { return DashConnectTokenPurchaseRequest( // A connection approved earlier for the same contract names the // app; otherwise the sheet falls back to the contract id. - appName: subject.value.first { $0.id == contractIdBase58 }?.name, + appName: rows(in: context.stores).first { $0.id == contractIdBase58 }?.name, ownerId: purchase.ownerId, dataContractId: purchase.dataContractId, tokenId: purchase.tokenId, @@ -813,25 +1480,335 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { } } - func remove(id: String) async { - persistAndSend(subject.value.filter { $0.id != id }) + func remove(id: String, scope: String?) async { + // A row published for another wallet was not picked from this one. + guard let stores = await showActiveWalletRows(), scope == stores.scope else { return } + await updateRows(in: stores) { rows in rows.filter { $0.id != id } } + } + + func disconnect(id: String, scope: String?) async throws { + try await gated { try await disconnectInTurn(id: id, scope: scope) } } - func disconnect(id: String) async { + private func disconnectInTurn(id: String, scope: String?) async throws { + guard let stores = await showActiveWalletRows() else { + throw DashConnectPlatformError.noWallet + } + // The row names the wallet it was published for. When that is not + // the wallet that is active now, nothing of the row's wallet can be + // turned off from here, and a row this wallet has for the same app + // is not the one that was picked. + guard scope == stores.scope else { + throw DashConnectPlatformError.walletChanged + } + let isOneQR = rows(in: stores).first { $0.id == id }?.loginKind == .oneQR + let hasGrants = stores.grants.load().contains { $0.appContractId == id } + if isOneQR || hasGrants { + let liveElsewhere = try await revokeAppConnectGrants( + appContractId: id, checksUnrecordedKeys: isOneQR, stores: stores) + // The row stands for the app, and the app still holds a key on + // another identity of this wallet. That key can only be disabled + // while its identity is the active one, so the row stays to get + // there, and the user is told; this identity's keys are off. + if liveElsewhere { throw DashConnectPlatformError.grantOnAnotherIdentity } + } + if isOneQR { + // The session keys are disabled, and so are the keys of an + // earlier two-QR login that were found: the same QR can no + // longer be approved and there is no second step to wait for, + // so the row goes instead of sitting in the two-QR "approved" + // state. + await updateRows(in: stores) { rows in rows.filter { $0.id != id } } + return + } + let disconnectedAt = now() - // Local-only: keep the published `loginKeyResponse` document intact. - // Re-approving the same `dash-key:` QR is enough to return to `.active` - // once the identity keys are still registered. - persistAndSend(subject.value.map { connection in - guard connection.id == id else { return connection } - return DAppConnection( - id: connection.id, - name: connection.name, - url: connection.url, - status: .approved, - updatedAt: disconnectedAt + // Local-only for a two-QR connection: its `loginKeyResponse` document + // stays, and re-approving the same `dash-key:` QR returns it to + // `.active` while the identity keys are still registered. + await updateRows(in: stores) { rows in + rows.map { connection in + guard connection.id == id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .approved, + updatedAt: disconnectedAt + ) + } + } + } + + /// Ends the access one-QR logins gave an app: disables its session keys + /// on the identity, then deletes the responses that carried them. + /// + /// The key goes first because it is what the app signs with. A response + /// that could not be deleted afterwards stays recorded, with its key + /// marked disabled, and is retried by the next login with this identity. + /// + /// The keys are the ones recorded here plus the live keys on the identity + /// that are bound to the app's contract and shaped as this login + /// registers them, so the keys a reinstalled wallet kept no record of go + /// too. A key another wallet gave the app in a different shape stays. + /// TODO(app-connect-orphan-responses): the response behind a key with no + /// record stays on Platform. It can only be found by its request hash, + /// which the record held. + /// + /// For a row made by the one-QR login the keys of an earlier two-QR + /// login to the app are disabled as well. That login derives the same keys + /// every time and Platform never re-adds a key, so the app cannot sign + /// in with two QRs again. A two-QR row keeps them. + /// + /// Returns whether a grant record shows the app with a live key on + /// another identity of this wallet. When this identity gave it none, that + /// is thrown instead: nothing was turned off. + /// For a row made by the one-QR login that also covers keys on another + /// identity that no grant record names, as far as the wallet's store + /// shows them. + /// + /// `checksUnrecordedKeys` is set for a row made by the one-QR login: with + /// no record for this identity the keys are still looked for on it. When + /// it holds none bound to the app, there was nothing to turn off here and + /// the call returns without an error. + private func revokeAppConnectGrants( + appContractId: String, + checksUnrecordedKeys: Bool, + stores: Stores + ) async throws -> Bool { + // The row was picked in the wallet `stores` belongs to; another + // wallet's identity is not the one the row belongs to. + var context = try await requireContext(pinnedTo: stores) + let identityId = context.identityId.toBase58String() + func isGranted(_ grant: AppConnectGrant) -> Bool { + grant.identityId == identityId && grant.appContractId == appContractId + } + func hasLiveGrantElsewhere() -> Bool { + stores.grants.load().contains { + $0.appContractId == appContractId && $0.identityId != identityId && $0.keyDisabled != true + } + } + guard let appContract = Self.decodeIdentifier(appContractId) else { + throw DashConnectPlatformError.loginContractUnavailable + } + if !stores.grants.load().contains(where: isGranted) { + // Before the PIN prompt, from the store alone: whether this + // identity holds a key of the app's that has no record. + let holdsUnrecordedKey = try checksUnrecordedKeys && Self + .storedKeyBindings(identityId: identityId, modelContainer: context.modelContainer) + .contains { $0.singleContractId == appContract && !$0.isDisabled && $0.isSessionKeyShaped } + // A one-QR row goes on to the PIN prompt either way: the keys of + // an earlier two-QR login can only be looked for after it. + if !holdsUnrecordedKey, !checksUnrecordedKeys { + if hasLiveGrantElsewhere() { throw DashConnectPlatformError.grantOnAnotherIdentity } + return false + } + } + + try await authorize() + context = try await contextAfterAuthorization(matching: context) + + let signer = KeychainSigner(modelContainer: context.modelContainer) + let currentPublicKeys = try await refreshedIdentityPublicKeys(context: context) + let granted = stores.grants.load().filter(isGranted) + guard AppConnect.sessionKeyIdsMissing(from: currentPublicKeys, for: granted).isEmpty else { + // Platform never removes a key, so the node that answered is + // behind. A key it does not know cannot be shown to be disabled, + // so nothing is reported as turned off. + throw DashConnectPlatformError.identityKeysOutOfDate + } + let bindings = try Self.storedKeyBindings(identityId: identityId, modelContainer: context.modelContainer) + guard AppConnect.sessionKeyIdsWithUnknownBinding( + for: granted, + bindings: bindings, + currentIdentityPublicKeys: currentPublicKeys + ).isEmpty, AppConnect.boundKeyIdsMissing( + from: currentPublicKeys, + appContractId: appContract, + bindings: bindings + ).isEmpty else { + // A live session key the wallet's store cannot place may be this + // app's, and a stored key of the app's that the fetched list + // lacks means the list is behind. Either way nothing is reported + // as turned off. + throw DashConnectPlatformError.identityKeysOutOfDate + } + // The keys the two-QR login derives for this app, known before + // anything is disabled: its authentication key has the kind and the + // bound of a session key. A one-QR row disables both of them; a + // two-QR row keeps them, so there the authentication key is named + // as one to leave. If they cannot be derived, nothing is turned off. + let twoQRKeys = try await twoQRLoginPublicKeys(appContractId: appContract, context: context) + var keyIds = AppConnect.sessionKeyIdsToDisable( + for: granted, + appContractId: appContract, + bindings: bindings, + currentIdentityPublicKeys: currentPublicKeys, + twoQRAuthenticationKeyHash160: checksUnrecordedKeys ? nil : twoQRKeys.authenticationPublicKeyHash160 + ) + if checksUnrecordedKeys { + keyIds = Array(Set(keyIds).union(Self.liveLoginKeyIds( + authenticationPublicKeyHash160: twoQRKeys.authenticationPublicKeyHash160, + encryptionPublicKey: twoQRKeys.encryptionPublicKey, + currentIdentityPublicKeys: currentPublicKeys + ))).sorted() + } + // For a one-QR row, the keys on another identity that no grant + // record shows. `nil` when that could not be told. + let heldOnAnotherIdentity: Bool? = checksUnrecordedKeys && !hasLiveGrantElsewhere() + ? await anotherIdentityHoldsAppKeys(appContractId: appContract, context: context) + : false + if granted.isEmpty, keyIds.isEmpty, hasLiveGrantElsewhere() || heldOnAnotherIdentity == true { + throw DashConnectPlatformError.grantOnAnotherIdentity + } + if !keyIds.isEmpty { + Self.logger.info("🔗 DASHCONNECT :: disabling \(keyIds.count, privacy: .public) app key(s)") + // Platform takes a limited number of keys per update. A failure + // part way leaves the rest live and the row in place; the next + // attempt picks up what is still live. + for start in stride(from: 0, to: keyIds.count, by: AppConnect.maxKeysDisabledPerUpdate) { + try await context.wallet.updateIdentity( + identityId: context.identityId, + disablePublicKeyIds: Array(keyIds[start ..< min(start + AppConnect.maxKeysDisabledPerUpdate, keyIds.count)]), + signer: signer + ) + } + Self.logger.info("🔗 DASHCONNECT :: app key(s) disabled") + } + + // The session keys of the grants read above are now off, and only + // those are marked. + let revoked = Set(granted.map(\.key)) + func isRevoked(_ grant: AppConnectGrant) -> Bool { revoked.contains(grant.key) } + updateGrants(in: stores) { AppConnect.markKeysDisabled(in: &$0, where: isRevoked) } + + if let responseContractId = Self.decodeIdentifier(AppConnect.contractId) { + await deleteAppConnectResponses( + where: isRevoked, + responseContractId: responseContractId, + context: context, + signer: signer ) - }) + } + if hasLiveGrantElsewhere() || heldOnAnotherIdentity == true { return true } + // This identity's keys are off. Whether another one holds a key of + // the app could not be read from the store, so the row stays, with + // an error rather than without one. + guard heldOnAnotherIdentity != nil else { throw DashConnectPlatformError.otherIdentitiesNotChecked } + return false + } + + /// Whether the wallet's store shows another identity of this wallet with + /// a live key of the app that no grant record names: a session key bound + /// to the app's contract, or a key the two-QR login derives for it. + /// `nil` when no identity shows one and at least one could not be told: + /// its keys are not in the store, a live session-shaped key there has a + /// bound this build cannot read, or its two-QR keys could not be derived. + /// TODO(app-connect-other-identity-keys): the store is as fresh as each + /// identity's last load from Platform, and an identity it does not hold + /// is not seen, so the row can go while such a key is live. + private func anotherIdentityHoldsAppKeys(appContractId: Data, context: Context) async -> Bool? { + let others: [(identityId: Data, identityIndex: UInt32)] + do { + others = try Self.storedWalletIdentities( + walletId: context.wallet.walletId, + network: context.network, + modelContainer: context.modelContainer + ).filter { $0.identityId != context.identityId } + } catch { + Self.logger.error( + "🔗 DASHCONNECT :: other identities not read — \(error.localizedDescription, privacy: .public)") + return nil + } + // One identity that cannot be told does not hide a key found on + // another. + var hasUnknown = false + for other in others { + do { + let bindings = try Self.storedKeyBindings( + identityId: other.identityId.toBase58String(), modelContainer: context.modelContainer) + if AppConnect.holdsLiveSessionKey(appContractId: appContractId, bindings: bindings) { return true } + // An identity always has keys, so none stored means not + // loaded; and a session-shaped key with an unreadable bound + // may be the app's. + if bindings.isEmpty || bindings.contains(where: { + !$0.isDisabled && $0.isSessionKeyShaped && $0.hasUnreadableBound + }) { + hasUnknown = true + } + // Derive only for an identity that has a live key of either + // two-QR kind at all. + guard bindings.contains(where: { + !$0.isDisabled && ($0.isTwoQRAuthenticationKeyShaped || $0.isTwoQREncryptionKeyShaped) + }) else { continue } + let twoQRKeys = try await twoQRLoginPublicKeys( + appContractId: appContractId, + identityId: other.identityId, + identityIndex: other.identityIndex, + context: context) + if AppConnect.holdsLiveTwoQRLoginKey( + authenticationPublicKeyHash160: twoQRKeys.authenticationPublicKeyHash160, + encryptionPublicKey: twoQRKeys.encryptionPublicKey, + bindings: bindings + ) { + return true + } + } catch { + Self.logger.error( + "🔗 DASHCONNECT :: keys of another identity not checked — \(error.localizedDescription, privacy: .public)") + hasUnknown = true + } + } + return hasUnknown ? nil : false + } + + /// The identities the SDK's store attaches to this wallet on `network`. + private static func storedWalletIdentities( + walletId: Data, + network: Network, + modelContainer: ModelContainer + ) throws -> [(identityId: Data, identityIndex: UInt32)] { + let descriptor = FetchDescriptor(predicate: PersistentIdentity.predicate(network: network)) + let modelContext = ModelContext(modelContainer) + // The rows are only readable while their context is alive. + return try withExtendedLifetime(modelContext) { + try modelContext.fetch(descriptor) + .filter { $0.wallet?.walletId == walletId } + .map { ($0.identityId, $0.identityIndex) } + } + } + + /// The public halves of the keys the two-QR login derives for the active + /// identity and an app. + private func twoQRLoginPublicKeys( + appContractId: Data, + context: Context + ) async throws -> (authenticationPublicKeyHash160: Data, encryptionPublicKey: Data) { + try await twoQRLoginPublicKeys( + appContractId: appContractId, + identityId: context.identityId, + identityIndex: context.identityIndex, + context: context) + } + + /// The same for any identity of the active wallet, by its index. + private func twoQRLoginPublicKeys( + appContractId: Data, + identityId: Data, + identityIndex: UInt32, + context: Context + ) async throws -> (authenticationPublicKeyHash160: Data, encryptionPublicKey: Data) { + var derivedMaterial = try await deriveKeyRegistrationMaterial( + appContractId: appContractId, + identityId: identityId, + identityIndex: identityIndex, + context: context) + defer { + Self.zero(&derivedMaterial.loginKey) + Self.zero(&derivedMaterial.authenticationPrivateKey) + Self.zero(&derivedMaterial.encryptionPrivateKey) + } + return (derivedMaterial.authenticationPublicKeyHash160, derivedMaterial.encryptionPublicKey) } static func buildLoginKeyResponseDraft( @@ -847,26 +1824,24 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { ) } ) throws -> DashConnectLoginKeyResponseDraft { - defer { zero(&walletEphemeralPrivateKey) } - - let appEphemeralPubKeyHash = try KeyExchangeCrypto.hash160(appEphemeralPubKey) - guard appEphemeralPubKeyHash.count == 20 else { - throw DashConnectPlatformError.invalidHash160 - } - - let walletEphemeralPublicKey = try Secp256k1.compressedPublicKey(privateKey: walletEphemeralPrivateKey) - let encryptedPayload = try encryptLoginKey(loginKey, walletEphemeralPrivateKey, appEphemeralPubKey) + let sealed = try AppConnect.seal( + loginKey, + appEphemeralPubKey: appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey, + encrypt: encryptLoginKey + ) + let values = AppConnect.ResponseValues(sealed) return DashConnectLoginKeyResponseDraft( properties: [ "contractId": appContractId.toBase58String(), - "appEphemeralPubKeyHash": appEphemeralPubKeyHash.toHexString(), - "walletEphemeralPubKey": walletEphemeralPublicKey.toHexString(), - "encryptedPayload": encryptedPayload.toHexString(), + "appEphemeralPubKeyHash": values.appEphemeralPubKeyHash, + "walletEphemeralPubKey": values.walletEphemeralPubKey, + "encryptedPayload": values.encryptedPayload, "keyIndex": LoginKeyDerivation.defaultKeyIndex, ], - walletEphemeralPublicKey: walletEphemeralPublicKey, - encryptedPayload: encryptedPayload + walletEphemeralPublicKey: sealed.walletEphemeralPublicKey, + encryptedPayload: sealed.encryptedPayload ) } @@ -895,6 +1870,42 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { ) } + /// The two-QR login's keys for the active identity and one app. The + /// caller wipes the returned private keys. + private func deriveKeyRegistrationMaterial( + appContractId: Data, + context: Context + ) async throws -> DashConnectKeyRegistrationDerivedMaterial { + try await deriveKeyRegistrationMaterial( + appContractId: appContractId, + identityId: context.identityId, + identityIndex: context.identityIndex, + context: context) + } + + /// The same for any identity of the active wallet, by its index. + private func deriveKeyRegistrationMaterial( + appContractId: Data, + identityId: Data, + identityIndex: UInt32, + context: Context + ) async throws -> DashConnectKeyRegistrationDerivedMaterial { + // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. + var chainKey = try await MainActor.run { + try context.wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: identityIndex, + keyId: UInt32(LoginKeyDerivation.defaultKeyIndex), + network: context.network + ).privateKeyData + } + defer { Self.zero(&chainKey) } + return try Self.deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + } + private static func deriveKeyRegistrationMaterial( chainKeyPrivateBytes: Data, identityId: Data, @@ -1008,6 +2019,27 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { ) } + /// The ids of the two-QR login's keys that are on the identity and not + /// disabled. + static func liveLoginKeyIds( + authenticationPublicKeyHash160: Data, + encryptionPublicKey: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> [UInt32] { + currentIdentityPublicKeys + .filter { key in + hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: authenticationPublicKeyHash160, + currentIdentityPublicKeys: [key] + ) || hasRegisteredEncryptionKey( + encryptionPublicKey: encryptionPublicKey, + currentIdentityPublicKeys: [key] + ) + } + .map { UInt32(bitPattern: $0.keyId) } + .sorted() + } + static func missingKeyRegistrationKeys( _ validated: DashConnectKeyRegistrationValidationResult, currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] @@ -1034,14 +2066,16 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { private static func makeConnection( preview: ConnectionRequest, status: ConnectionStatus, - updatedAt: Date + updatedAt: Date, + loginKind: DashConnectLoginKind = .twoQR ) -> DAppConnection { DAppConnection( id: preview.appContractId, name: preview.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : preview.appLabel, url: preview.appUrl, status: status, - updatedAt: updatedAt + updatedAt: updatedAt, + loginKind: loginKind ) } @@ -1071,23 +2105,61 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { } } - private func persistAndSend(_ connections: [DAppConnection]) { - let sorted = connections.sorted(by: { $0.updatedAt > $1.updatedAt }) - store.save(sorted) - subject.send(sorted) + /// The stores fixed to one wallet; `nil` with no wallet. + private static func pinnedStores( + walletIdHex: String?, + store: any DashConnectStore, + responseStore: any AppConnectResponseStore, + endpoint: String? + ) -> Stores? { + guard let walletIdHex, !walletIdHex.isEmpty else { return nil } + return Stores( + walletIdHex: walletIdHex, + connections: store.pinned(toWalletIdHex: walletIdHex), + grants: responseStore.pinned(toWalletIdHex: walletIdHex), + endpoint: endpoint) } - private func existingConnection(for contractId: String) -> DAppConnection? { - subject.value.first { $0.id == contractId } + /// The stores fixed to the wallet that is active at this call, read + /// once; `nil` with no wallet. + private func pinnedActiveStores() -> Stores? { + Self.pinnedStores( + walletIdHex: activeWalletIdHex(), store: store, responseStore: responseStore, + endpoint: networkEndpoint()) } - private func pendingApprovedConnectionForKeyRegistration( - boundContractId: Data? - ) throws -> DAppConnection { - try Self.pendingApprovedConnectionForKeyRegistration( - in: subject.value, - boundContractId: boundContractId - ) + /// Publishes the active wallet's rows when the published list holds + /// another wallet's. Returns the active wallet's stores, read once. + @MainActor + @discardableResult + private func showActiveWalletRows() -> Stores? { + let active = pinnedActiveStores() + guard shownRows.scope != active?.scope else { return active } + shownRows.scope = active?.scope + subject.send(active.map { $0.published($0.connections.load()) } ?? []) + return active + } + + /// The rows of the wallet `stores` belongs to, as stored. Not the + /// published list: another data source over the same store may have + /// written since this one loaded it. + private func rows(in stores: Stores) -> [DAppConnection] { + stores.connections.load() + } + + @MainActor + private func shownRow(id: String) -> DAppConnection? { + subject.value.first { $0.id == id } + } + + /// Changes the rows of the wallet `stores` belongs to in one step, saves + /// them there, and publishes them only while the published list is that + /// wallet's. + @MainActor + private func updateRows(in stores: Stores, _ change: ([DAppConnection]) -> [DAppConnection]) { + let sorted = Stores.stored(change(rows(in: stores))).sorted(by: { $0.updatedAt > $1.updatedAt }) + stores.connections.save(sorted) + if shownRows.scope == stores.scope { subject.send(stores.published(sorted)) } } /// The contract every bounded key in the transition points at, or `nil` @@ -1184,6 +2256,62 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { } } + /// The identity's keys as Platform has them now. The SDK's own copy is + /// only replaced when an identity update returns success, so a + /// registration or a disable that landed without confirming is missing + /// from it; deciding from that copy would add the key twice or disable it + /// twice. The reload also replaces the copy the SDK signs the next update + /// against. + private func refreshedIdentityPublicKeys( + context: Context + ) async throws -> [ManagedIdentity.IdentityPublicKeyInfo] { + guard try await context.wallet.loadIdentity(atIndex: context.identityIndex) == context.identityId else { + throw DashConnectPlatformError.noIdentity + } + return try context.wallet + .managedIdentity(identityId: context.identityId) + .getPublicKeys() + } + + /// What the wallet's store holds for each key of the identity. The store + /// is written by the SDK as it loads and updates the identity. + private static func storedKeyBindings( + identityId: String, + modelContainer: ModelContainer + ) throws -> [AppConnect.KeyBinding] { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.identityId == identityId }) + let modelContext = ModelContext(modelContainer) + // The rows are only readable while their context is alive. + return try withExtendedLifetime(modelContext) { + try modelContext.fetch(descriptor).map { + AppConnect.KeyBinding( + keyId: UInt32(bitPattern: $0.keyId), + data: $0.publicKeyData, + singleContractId: AppConnect.singleContractId( + boundsKind: $0.effectiveContractBoundsKind, + boundIds: $0.contractBounds + ), + isDisabled: $0.disabledAt != nil, + isSessionKeyShaped: $0.keyTypeEnum == .ecdsaHash160 + && $0.purposeEnum == .authentication + && $0.securityLevelEnum == .high + && $0.totalBudget != nil + && $0.expiresAt != nil, + hasUnreadableBound: !AppConnect.isReadableBound( + boundsKind: $0.effectiveContractBoundsKind, + boundIds: $0.contractBounds + ), + isTwoQRAuthenticationKeyShaped: $0.keyTypeEnum == .ecdsaHash160 + && $0.purposeEnum == .authentication + && $0.securityLevelEnum == .high, + isTwoQREncryptionKeyShaped: $0.keyTypeEnum == .ecdsaSecp256k1 + && $0.purposeEnum == .encryption + && $0.securityLevelEnum == .medium + ) + } + } + } + private func requireContext() async throws -> Context { let runtime = await MainActor.run { ( @@ -1222,6 +2350,14 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { throw DashConnectPlatformError.noIdentity } + // The stores below are fixed to this wallet. While a switch is under + // way the SDK can still hold the wallet that is being left. + let walletIdHex = wallet.walletId.hexEncodedString() + guard activeWalletIdHex() == walletIdHex else { + Self.logger.error("🔗 DASHCONNECT :: the SDK wallet is not the active wallet") + throw DashConnectPlatformError.walletChanged + } + let identityIndex = try wallet.managedIdentity(identityId: identityId).getIdentityIndex() ?? 0 return Context( @@ -1231,7 +2367,16 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { network: network, identityId: identityId, identityIndex: identityIndex, - storedUsername: runtime.storedUsername + storedUsername: runtime.storedUsername, + stores: Stores( + walletIdHex: walletIdHex, + connections: store.pinned(toWalletIdHex: walletIdHex), + grants: responseStore.pinned(toWalletIdHex: walletIdHex), + // TODO(dashconnect-endpoint-source): this is the endpoint + // configured now, not the one `sdk` above was built with. + // They agree while the only writer of the setting restarts + // the runtime before it writes. + endpoint: networkEndpoint()) ) } @@ -1433,7 +2578,9 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { case .singleContractDocumentType(let id, let documentTypeName): return .singleContractDocumentType(id: id, documentTypeName: documentTypeName) } - } + }, + totalBudget: key.totalBudget, + expiresAt: key.expiresAt ) } diff --git a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift index 6472c0b50b..3419e1d38f 100644 --- a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift +++ b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift @@ -38,6 +38,9 @@ struct ApproveConnectionSheet: View { /// would otherwise see nothing at all. var errorText: String? var onApprove: () -> Void + /// Asks again which login this would be. The sheet offers it in place of + /// Approve while that is unresolved. + var onRetry: () -> Void = {} var onDeny: () -> Void var body: some View { @@ -64,13 +67,47 @@ struct ApproveConnectionSheet: View { Text(resolvedSubtitle) .font(.subhead) .foregroundColor(.secondaryText) + + // The name above can be one the code or the + // contract's owner chose. This id is what the + // approval is for: a one-QR key is bound to it. Shown + // in full and selectable, so it can be compared with + // the one the app publishes. + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("App contract", comment: "DashConnect")) + .font(.footnote) + .foregroundColor(.secondaryText) + Text(request.appContractId) + .font(.footnote.monospaced()) + .foregroundColor(.primaryText) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 6) + .accessibilityElement(children: .combine) } if let existingConnection = request.existingConnection { reloginNotice(existingConnection) } - if isRelogin { + // The one-QR login gives the app a key with limits of its + // own, so its terms are spelled out on a repeat sign-in + // too, and when it replaces a two-QR connection. + if request.login == .unresolved { + Text(NSLocalizedString( + "This wallet could not check what this sign-in would give the app.", + comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + if let reason = request.unresolvedLoginReason { + Text(reason) + .font(.footnote) + .foregroundColor(.secondaryText) + } + } else if let terms = request.login.sessionKeyTerms { + sessionKeyPermissions(terms) + } else if isRelogin { reloginPermissionsSummary } else { fullPermissions @@ -116,13 +153,16 @@ struct ApproveConnectionSheet: View { } DashButton( - text: NSLocalizedString("Approve", comment: "DashConnect"), + // With the login unresolved there is nothing to approve. + text: request.login == .unresolved + ? NSLocalizedString("Try again", comment: "") + : NSLocalizedString("Approve", comment: "DashConnect"), style: .filledBlue, size: .large, stretch: true, isEnabled: !isLoading, isLoading: isLoading, - action: onApprove + action: request.login == .unresolved ? onRetry : onApprove ) DashButton( @@ -168,20 +208,65 @@ struct ApproveConnectionSheet: View { } private var fullPermissions: some View { + permissions( + granted: [ + NSLocalizedString("See your username", comment: "DashConnect"), + NSLocalizedString("Verify your identity", comment: "DashConnect"), + ], + withheld: [ + NSLocalizedString("Your private keys", comment: "DashConnect"), + NSLocalizedString("Withdraw funds", comment: "DashConnect"), + ] + ) + } + + /// The one-QR login gives the app a key of its own on the identity, so + /// the sheet names what that key can spend and when it stops working. + private func sessionKeyPermissions(_ terms: AppConnect.SessionKeyTerms) -> some View { + VStack(alignment: .leading, spacing: 20) { + permissions( + granted: [ + NSLocalizedString("See your username", comment: "DashConnect"), + NSLocalizedString("Verify your identity", comment: "DashConnect"), + NSLocalizedString("Act as you in this app without asking each time", comment: "DashConnect one-QR login"), + // Per sign-in: an earlier sign-in to the app keeps its + // own key and limit until the app is disconnected. + String( + format: NSLocalizedString( + "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit.", + comment: "DashConnect one-QR login: the placeholder is an amount with its unit, e.g. 0.05 DASH"), + terms.budgetText + ), + ], + withheld: [ + NSLocalizedString("Your recovery phrase or wallet keys", comment: "DashConnect one-QR login"), + NSLocalizedString("Your Dash balance", comment: "DashConnect one-QR login"), + ] + ) + + Text( + String( + format: NSLocalizedString( + "This access ends after %d days, or when you disconnect the app.", + comment: "DashConnect one-QR login"), + terms.lifetimeDays + ) + ) + .font(.footnote) + .foregroundColor(.secondaryText) + } + } + + private func permissions(granted: [String], withheld: [String]) -> some View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 12) { Text(NSLocalizedString("This app will be able to:", comment: "DashConnect")) .font(.subhead) .foregroundColor(.primaryText) - PermissionRow( - icon: .custom("dashconnect.check.circle", maxHeight: 18), - text: NSLocalizedString("See your username", comment: "DashConnect") - ) - PermissionRow( - icon: .custom("dashconnect.check.circle", maxHeight: 18), - text: NSLocalizedString("Verify your identity", comment: "DashConnect") - ) + ForEach(granted, id: \.self) { text in + PermissionRow(icon: .custom("dashconnect.check.circle", maxHeight: 18), text: text) + } } VStack(alignment: .leading, spacing: 12) { @@ -189,14 +274,9 @@ struct ApproveConnectionSheet: View { .font(.subhead) .foregroundColor(.primaryText) - PermissionRow( - icon: .custom("dashconnect.xmark.circle", maxHeight: 18), - text: NSLocalizedString("Your private keys", comment: "DashConnect") - ) - PermissionRow( - icon: .custom("dashconnect.xmark.circle", maxHeight: 18), - text: NSLocalizedString("Withdraw funds", comment: "DashConnect") - ) + ForEach(withheld, id: \.self) { text in + PermissionRow(icon: .custom("dashconnect.xmark.circle", maxHeight: 18), text: text) + } } } } @@ -248,13 +328,14 @@ private struct PermissionRow: View { let text: String var body: some View { - HStack(spacing: 6) { + HStack(alignment: .top, spacing: 6) { Icon(name: icon) .frame(width: 18, height: 18) Text(text) .font(.subhead) .foregroundColor(.primaryText) + .fixedSize(horizontal: false, vertical: true) } } } @@ -309,6 +390,22 @@ private let dashConnectTruncateMiddleCheck: Bool = { ) } +#Preview("Approve One-QR") { + ApproveConnectionSheet( + request: ConnectionRequest( + loginRequest: MockDashConnectDataSource.sampleLoginRequest, + appLabel: "Yappr", + appUrl: "yap.pr", + walletUsername: "dashuser", + walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8", + existingConnection: nil, + login: .oneQR(.current) + ), + onApprove: {}, + onDeny: {} + ) +} + #Preview("Approve Re-Login") { ApproveConnectionSheet( request: ConnectionRequest( @@ -317,7 +414,8 @@ private let dashConnectTruncateMiddleCheck: Bool = { appUrl: "yap.pr", walletUsername: "dashuser", walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8", - existingConnection: MockDashConnectDataSource.sample(.active) + existingConnection: MockDashConnectDataSource.sample(.active), + login: .twoQR ), onApprove: {}, onDeny: {} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift b/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift index 49b2d99318..ef9ec48bf8 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift @@ -24,19 +24,21 @@ import SwiftUI /// once rather than repeated per availability branch. struct ApproveSheetPresentation: ViewModifier { let isLoading: Bool + /// Full height, for content that does not fit the fixed one. + var isTall = false - private let detentHeight: CGFloat = 620 + private var detent: PresentationDetent { isTall ? .large : .height(620) } private let cornerRadius: CGFloat = 32 func body(content: Content) -> some View { if #available(iOS 16.4, *) { content - .presentationDetents([.height(detentHeight)]) + .presentationDetents([detent]) .presentationCornerRadius(cornerRadius) .interactiveDismissDisabled(isLoading) } else if #available(iOS 16.0, *) { content - .presentationDetents([.height(detentHeight)]) + .presentationDetents([detent]) .interactiveDismissDisabled(isLoading) } else { content @@ -46,7 +48,7 @@ struct ApproveSheetPresentation: ViewModifier { extension View { /// Presents this view as the approve-connection sheet body. - func approveSheetPresentation(isLoading: Bool) -> some View { - modifier(ApproveSheetPresentation(isLoading: isLoading)) + func approveSheetPresentation(isLoading: Bool, isTall: Bool = false) -> some View { + modifier(ApproveSheetPresentation(isLoading: isLoading, isTall: isTall)) } } diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift index e519e95730..00ac7e770c 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift @@ -87,9 +87,11 @@ struct ConnectionRow: View { } /// Always reads as on: the row only shows it for an active connection, and - /// only the off direction is reachable from here. Turning it off locally - /// downgrades the connection back to `.approved`; it does not sign the user - /// out of the app, because the wallet has no channel to end that session. + /// only the off direction is reachable from here. What turning it off does + /// depends on the login that made the connection (see + /// `DashConnectDataSource.disconnect`): a one-QR connection has the app's + /// key disabled on the identity and its row removed; a two-QR connection + /// is downgraded to `.approved` locally and the app keeps its keys. private var activeSwitch: some View { ZStack { SwitchView(isOn: .constant(true)) diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift index 3ac6074272..cba07666f1 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift @@ -51,7 +51,7 @@ struct ConnectionsScreen: View { content } - if viewModel.isProcessingStateTransition { + if viewModel.isProcessingStateTransition || viewModel.isDisconnecting { Color.black.opacity(0.08) .ignoresSafeArea() @@ -111,9 +111,12 @@ struct ConnectionsScreen: View { isLoading: viewModel.isApproving, errorText: viewModel.approveError, onApprove: { viewModel.approvePendingRequest() }, + onRetry: { viewModel.retryPendingRequest() }, onDeny: { viewModel.denyPendingRequest() } ) - .approveSheetPresentation(isLoading: viewModel.isApproving) + // The one-QR terms make the sheet longer than the fixed height + // shows; who pays and when access ends must not sit below the fold. + .approveSheetPresentation(isLoading: viewModel.isApproving, isTall: request.login.sessionKeyTerms != nil) } private func tokenPurchaseSheet(for purchase: DashConnectTokenPurchaseRequest) -> some View { diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift index bdf6351e7d..bb55d28b3f 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -52,6 +52,10 @@ final class ConnectionsViewModel: ObservableObject { @Published var isApproving = false @Published var isApprovingPurchase = false @Published var isProcessingStateTransition = false + /// A disconnect that is signing an identity update. Its own flag, not + /// `isProcessingStateTransition`: a `dash-st:` task clears that one when + /// it finishes, which would lift the guard while a disconnect is still out. + @Published private(set) var isDisconnecting = false @Published var message: ConnectionsScreenMessage? /// Failure of the last approve attempt, rendered **inside** the approve sheet. /// A screen-level `.alert` cannot appear over a presented sheet, so routing this @@ -128,7 +132,7 @@ final class ConnectionsViewModel: ObservableObject { return } - guard !isProcessingStateTransition else { + guard !isProcessingStateTransition, !isDisconnecting else { message = ConnectionsScreenMessage( kind: .error, text: NSLocalizedString("Finish the current DashConnect request first, then try again.", @@ -196,7 +200,10 @@ final class ConnectionsViewModel: ObservableObject { // `isResolvingRequest` guards the window where a newer request has been // accepted but has not published yet: approving during it would // authorize the request on screen moments before it is replaced. - guard pendingRequest != nil, let pendingLoginRequest, !isApproving, !isResolvingRequest else { return } + // An unresolved login has nothing to approve; the sheet offers + // `retryPendingRequest` for it instead. + guard let consent = pendingRequest, consent.login != .unresolved, + let pendingLoginRequest, !isApproving, !isResolvingRequest else { return } isApproving = true approveError = nil @@ -205,10 +212,27 @@ final class ConnectionsViewModel: ObservableObject { defer { isApproving = false } do { - _ = try await dataSource.approveLogin(pendingLoginRequest) + _ = try await dataSource.approveLogin(pendingLoginRequest, consent: consent) self.pendingRequest = nil self.pendingLoginRequest = nil self.approveError = nil + } catch DashConnectPlatformError.approvalOutOfDate { + // Another identity, wallet or network became active, or the + // network now answers with the other login: what the sheet + // showed is not what would run. Nothing was signed. The sheet is rebuilt from + // the same request as it stands now, for a new decision. + self.pendingRequest = await dataSource.makeConnectionRequest(from: pendingLoginRequest) + self.approveError = DashConnectPlatformError.approvalOutOfDate.localizedDescription + } catch DashConnectPlatformError.loginUnresolved { + // The wallet could not check which login this network gets. + // Nothing was signed. The sheet is rebuilt: when it still + // cannot tell, it says so itself and offers "Try again" + // instead of Approve; when it can again, the note says why + // the approval did not go through. + let rebuilt = await dataSource.makeConnectionRequest(from: pendingLoginRequest) + self.pendingRequest = rebuilt + self.approveError = rebuilt.login == .unresolved + ? nil : DashConnectPlatformError.loginUnresolved.localizedDescription } catch { // Keep the sheet up so the user can retry without rescanning the QR. self.approveError = String( @@ -219,6 +243,20 @@ final class ConnectionsViewModel: ObservableObject { } } + /// Builds the pending request again, for a sheet that could not tell + /// which login it would run. Approves nothing. + func retryPendingRequest() { + guard pendingRequest != nil, let pendingLoginRequest, !isApproving, !isResolvingRequest else { return } + + isApproving = true + approveError = nil + + Task { + defer { isApproving = false } + self.pendingRequest = await dataSource.makeConnectionRequest(from: pendingLoginRequest) + } + } + func denyPendingRequest() { guard !isApproving else { return } pendingRequest = nil @@ -281,14 +319,31 @@ final class ConnectionsViewModel: ObservableObject { } func disconnect(_ connection: DAppConnection) { - Task { - await dataSource.disconnect(id: connection.id) + // Disconnecting a one-QR connection signs an identity update, which + // takes seconds. It must not overlap anything else that signs one or + // touches the same records: a second tap, an approval on screen or in + // flight, a request still resolving, a `dash-st:` in progress. + guard !isDisconnecting, !isProcessingStateTransition, !isApproving, !isApprovingPurchase, + !isResolvingRequest, pendingRequest == nil, pendingTokenPurchase == nil else { return } + isDisconnecting = true + Task { [weak self] in + guard let self else { return } + defer { self.isDisconnecting = false } + do { + try await self.dataSource.disconnect(id: connection.id, scope: connection.scope) + } catch { + self.message = ConnectionsScreenMessage( + kind: .error, + text: String( + format: NSLocalizedString("Could not disconnect: %@", comment: "DashConnect"), + error.localizedDescription)) + } } } func removeConnection(_ connection: DAppConnection) { Task { - await dataSource.remove(id: connection.id) + await dataSource.remove(id: connection.id, scope: connection.scope) } } diff --git a/DashWallet/ar.lproj/Localizable.strings b/DashWallet/ar.lproj/Localizable.strings index b7e1ba7721..d5584d5e61 100644 --- a/DashWallet/ar.lproj/Localizable.strings +++ b/DashWallet/ar.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "تم تسجيل ”%@“. هل تريد إرسال طلب جهة اتصال إلى الشخص الذي دعاك؟"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/bg.lproj/Localizable.strings b/DashWallet/bg.lproj/Localizable.strings index 959cbf52f2..6513b3f3a2 100644 --- a/DashWallet/bg.lproj/Localizable.strings +++ b/DashWallet/bg.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ беше регистрирано. Да се изпрати ли заявка за контакт до човека, който ви покани?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/ca.lproj/Localizable.strings b/DashWallet/ca.lproj/Localizable.strings index a52361094c..61c8accaf6 100644 --- a/DashWallet/ca.lproj/Localizable.strings +++ b/DashWallet/ca.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» s'ha registrat. Vols enviar una sol·licitud de contacte a la persona que t'ha convidat?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/cs.lproj/Localizable.strings b/DashWallet/cs.lproj/Localizable.strings index 4ccf33f284..2e3871c3fc 100644 --- a/DashWallet/cs.lproj/Localizable.strings +++ b/DashWallet/cs.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ bylo zaregistrováno. Poslat žádost o kontakt osobě, která vás pozvala?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/da.lproj/Localizable.strings b/DashWallet/da.lproj/Localizable.strings index 24bcc296da..2be25a49a3 100644 --- a/DashWallet/da.lproj/Localizable.strings +++ b/DashWallet/da.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” er blevet registreret. Vil du sende en kontaktanmodning til den person, der inviterede dig?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/de.lproj/Localizable.strings b/DashWallet/de.lproj/Localizable.strings index b68953730a..c04bbdef51 100644 --- a/DashWallet/de.lproj/Localizable.strings +++ b/DashWallet/de.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ wurde registriert. Möchtest du der Person, die dich eingeladen hat, eine Kontaktanfrage senden?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/el.lproj/Localizable.strings b/DashWallet/el.lproj/Localizable.strings index 9cc83637f3..ca9a4f2c2f 100644 --- a/DashWallet/el.lproj/Localizable.strings +++ b/DashWallet/el.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "Το «%@» καταχωρίστηκε. Να σταλεί αίτημα επαφής στο άτομο που σας προσκάλεσε;"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 7ad66de2d6..0a841eced2 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -6103,9 +6103,27 @@ "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; /* Wallet preparation and support */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Help" = "Help"; "Couldn't open your wallet data" = "Couldn't open your wallet data"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "There isn't enough free space to prepare your wallet. Free up storage in iPhone Settings, then try again. Do not delete this app." = "There isn't enough free space to prepare your wallet. Free up storage in iPhone Settings, then try again. Do not delete this app."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; +"Your Dash balance" = "Your Dash balance"; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "Your wallet could not be opened. Do not delete this app. Try again or contact support for help." = "Your wallet could not be opened. Do not delete this app. Try again or contact support for help."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/eo.lproj/Localizable.strings b/DashWallet/eo.lproj/Localizable.strings index 00b2de2433..7d7a6c59ad 100644 --- a/DashWallet/eo.lproj/Localizable.strings +++ b/DashWallet/eo.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ estis registrita. Ĉu sendi kontaktpeton al la persono kiu vin invitis?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/es.lproj/Localizable.strings b/DashWallet/es.lproj/Localizable.strings index 063d294700..f003db3bff 100644 --- a/DashWallet/es.lproj/Localizable.strings +++ b/DashWallet/es.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» se ha registrado. ¿Quieres enviar una solicitud de contacto a la persona que te invitó?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/et.lproj/Localizable.strings b/DashWallet/et.lproj/Localizable.strings index 26f5a7b707..33723de595 100644 --- a/DashWallet/et.lproj/Localizable.strings +++ b/DashWallet/et.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ on registreeritud. Kas saata kontaktitaotlus inimesele, kes sind kutsus?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/fa.lproj/Localizable.strings b/DashWallet/fa.lproj/Localizable.strings index d350ebbe05..959d79d3c7 100644 --- a/DashWallet/fa.lproj/Localizable.strings +++ b/DashWallet/fa.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» ثبت شد. برای کسی که شما را دعوت کرده درخواست تماس فرستاده شود؟"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/fi.lproj/Localizable.strings b/DashWallet/fi.lproj/Localizable.strings index 873de00936..0cfe4c369c 100644 --- a/DashWallet/fi.lproj/Localizable.strings +++ b/DashWallet/fi.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "”%@” on rekisteröity. Lähetetäänkö yhteyspyyntö sinut kutsuneelle henkilölle?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/fil.lproj/Localizable.strings b/DashWallet/fil.lproj/Localizable.strings index f587a7f93d..aae50ada2e 100644 --- a/DashWallet/fil.lproj/Localizable.strings +++ b/DashWallet/fil.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "Nairehistro na ang “%@”. Magpadala ng kahilingan sa pakikipag-ugnay sa taong nag-imbita sa iyo?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/fr.lproj/Localizable.strings b/DashWallet/fr.lproj/Localizable.strings index 73a1c57deb..86b428a091 100644 --- a/DashWallet/fr.lproj/Localizable.strings +++ b/DashWallet/fr.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "« %@ » a été enregistré. Envoyer une demande de contact à la personne qui vous a invité ?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/hr.lproj/Localizable.strings b/DashWallet/hr.lproj/Localizable.strings index 3b2706d41c..dc17aad4bd 100644 --- a/DashWallet/hr.lproj/Localizable.strings +++ b/DashWallet/hr.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ je registrirano. Poslati zahtjev za kontakt osobi koja vas je pozvala?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/hu.lproj/Localizable.strings b/DashWallet/hu.lproj/Localizable.strings index 57b9f7b9ba..71a486ab7c 100644 --- a/DashWallet/hu.lproj/Localizable.strings +++ b/DashWallet/hu.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "A(z) „%@” regisztrálva. Küldesz kapcsolatkérést annak, aki meghívott?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/id.lproj/Localizable.strings b/DashWallet/id.lproj/Localizable.strings index 84c60a462c..46ef9edb0b 100644 --- a/DashWallet/id.lproj/Localizable.strings +++ b/DashWallet/id.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” telah terdaftar. Kirim permintaan kontak ke orang yang mengundang Anda?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/it.lproj/Localizable.strings b/DashWallet/it.lproj/Localizable.strings index 8312cc323b..c511c7a40a 100644 --- a/DashWallet/it.lproj/Localizable.strings +++ b/DashWallet/it.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» è stato registrato. Vuoi inviare una richiesta di contatto alla persona che ti ha invitato?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/ja.lproj/Localizable.strings b/DashWallet/ja.lproj/Localizable.strings index ae09c8757f..ab7432cd4f 100644 --- a/DashWallet/ja.lproj/Localizable.strings +++ b/DashWallet/ja.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "「%@」が登録されました。招待してくれた相手に連絡先リクエストを送りますか?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/ko.lproj/Localizable.strings b/DashWallet/ko.lproj/Localizable.strings index e897b42ded..9e3d1784b8 100644 --- a/DashWallet/ko.lproj/Localizable.strings +++ b/DashWallet/ko.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@”이(가) 등록되었습니다. 초대해 준 사람에게 연락처 요청을 보낼까요?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/mk.lproj/Localizable.strings b/DashWallet/mk.lproj/Localizable.strings index 12fc77356f..eb93acf92f 100644 --- a/DashWallet/mk.lproj/Localizable.strings +++ b/DashWallet/mk.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ е регистрирано. Да се испрати барање за контакт до лицето што ве покани?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/ms.lproj/Localizable.strings b/DashWallet/ms.lproj/Localizable.strings index 0195c80d72..8a253e4806 100644 --- a/DashWallet/ms.lproj/Localizable.strings +++ b/DashWallet/ms.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” telah didaftarkan. Hantar permintaan kenalan kepada orang yang menjemput anda?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/nb.lproj/Localizable.strings b/DashWallet/nb.lproj/Localizable.strings index 24f9a41a6a..fbebbcd175 100644 --- a/DashWallet/nb.lproj/Localizable.strings +++ b/DashWallet/nb.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» er registrert. Vil du sende en kontaktforespørsel til personen som inviterte deg?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/nl.lproj/Localizable.strings b/DashWallet/nl.lproj/Localizable.strings index 5f7bd14a5a..425976bdc3 100644 --- a/DashWallet/nl.lproj/Localizable.strings +++ b/DashWallet/nl.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” is geregistreerd. Wil je een contactverzoek sturen naar de persoon die je heeft uitgenodigd?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/pl.lproj/Localizable.strings b/DashWallet/pl.lproj/Localizable.strings index 7470646aad..217733ed48 100644 --- a/DashWallet/pl.lproj/Localizable.strings +++ b/DashWallet/pl.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@” zostało zarejestrowane. Wysłać zaproszenie do kontaktów osobie, która Cię zaprosiła?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/pt.lproj/Localizable.strings b/DashWallet/pt.lproj/Localizable.strings index 6c99082afb..b17e728442 100644 --- a/DashWallet/pt.lproj/Localizable.strings +++ b/DashWallet/pt.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” foi registrado. Enviar uma solicitação de contato para a pessoa que convidou você?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/ro.lproj/Localizable.strings b/DashWallet/ro.lproj/Localizable.strings index d3ee7c149d..cfb663d632 100644 --- a/DashWallet/ro.lproj/Localizable.strings +++ b/DashWallet/ro.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@” a fost înregistrat. Trimiți o cerere de contact persoanei care te-a invitat?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/ru.lproj/Localizable.strings b/DashWallet/ru.lproj/Localizable.strings index b046a26ad8..8014a410b0 100644 --- a/DashWallet/ru.lproj/Localizable.strings +++ b/DashWallet/ru.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» зарегистрировано. Отправить запрос на добавление в контакты человеку, который вас пригласил?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/sk.lproj/Localizable.strings b/DashWallet/sk.lproj/Localizable.strings index cd9ab9aa26..8a1b4e4fdc 100644 --- a/DashWallet/sk.lproj/Localizable.strings +++ b/DashWallet/sk.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ bolo zaregistrované. Poslať žiadosť o kontakt osobe, ktorá vás pozvala?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/sl.lproj/Localizable.strings b/DashWallet/sl.lproj/Localizable.strings index e96a78cb79..cbb0e002c1 100644 --- a/DashWallet/sl.lproj/Localizable.strings +++ b/DashWallet/sl.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "»%@« je bilo registrirano. Želite poslati zahtevo za stik osebi, ki vas je povabila?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/sl_SI.lproj/Localizable.strings b/DashWallet/sl_SI.lproj/Localizable.strings index 27684b4bf6..79c4d18d20 100644 --- a/DashWallet/sl_SI.lproj/Localizable.strings +++ b/DashWallet/sl_SI.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "»%@« je bilo registrirano. Želite poslati zahtevo za stik osebi, ki vas je povabila?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/sq.lproj/Localizable.strings b/DashWallet/sq.lproj/Localizable.strings index 90bd026198..7b85f6132b 100644 --- a/DashWallet/sq.lproj/Localizable.strings +++ b/DashWallet/sq.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ u regjistrua. Të dërgohet një kërkesë kontakti te personi që ju ftoi?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/sr.lproj/Localizable.strings b/DashWallet/sr.lproj/Localizable.strings index dcf5990d53..47deb31831 100644 --- a/DashWallet/sr.lproj/Localizable.strings +++ b/DashWallet/sr.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "„%@“ je registrovano. Poslati zahtev za kontakt osobi koja vas je pozvala?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/sv.lproj/Localizable.strings b/DashWallet/sv.lproj/Localizable.strings index 3e122bf181..1d59fbc1ff 100644 --- a/DashWallet/sv.lproj/Localizable.strings +++ b/DashWallet/sv.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "”%@” har registrerats. Vill du skicka en kontaktförfrågan till personen som bjöd in dig?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/th.lproj/Localizable.strings b/DashWallet/th.lproj/Localizable.strings index c072df3895..6c3407daf0 100644 --- a/DashWallet/th.lproj/Localizable.strings +++ b/DashWallet/th.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "ลงทะเบียน “%@” แล้ว ต้องการส่งคำขอการติดต่อไปยังคนที่เชิญคุณไหม"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/tr.lproj/Localizable.strings b/DashWallet/tr.lproj/Localizable.strings index 67bd2abe5f..eb3d66858a 100644 --- a/DashWallet/tr.lproj/Localizable.strings +++ b/DashWallet/tr.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” kaydedildi. Sizi davet eden kişiye bir iletişim isteği gönderilsin mi?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/uk.lproj/Localizable.strings b/DashWallet/uk.lproj/Localizable.strings index 7eed7bc642..9ef5646a8a 100644 --- a/DashWallet/uk.lproj/Localizable.strings +++ b/DashWallet/uk.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "«%@» зареєстровано. Надіслати запит на додавання до контактів людині, яка вас запросила?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/vi.lproj/Localizable.strings b/DashWallet/vi.lproj/Localizable.strings index 085d905758..daf0181b9c 100644 --- a/DashWallet/vi.lproj/Localizable.strings +++ b/DashWallet/vi.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@” đã được đăng ký. Gửi yêu cầu liên hệ tới người đã mời bạn chứ?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/zh-Hans.lproj/Localizable.strings b/DashWallet/zh-Hans.lproj/Localizable.strings index e394388742..60e387599a 100644 --- a/DashWallet/zh-Hans.lproj/Localizable.strings +++ b/DashWallet/zh-Hans.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@”已注册。要向邀请您的人发送联系人请求吗?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/zh-Hant-TW.lproj/Localizable.strings b/DashWallet/zh-Hant-TW.lproj/Localizable.strings index a1452fd025..52df208568 100644 --- a/DashWallet/zh-Hant-TW.lproj/Localizable.strings +++ b/DashWallet/zh-Hant-TW.lproj/Localizable.strings @@ -6214,20 +6214,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "「%@」已註冊。要向邀請您的人發送聯繫請求嗎?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/zh.lproj/Localizable.strings b/DashWallet/zh.lproj/Localizable.strings index aa585bcc34..dcc16565d8 100644 --- a/DashWallet/zh.lproj/Localizable.strings +++ b/DashWallet/zh.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "“%@”已注册。要向邀请您的人发送联系人请求吗?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWallet/zh_TW.lproj/Localizable.strings b/DashWallet/zh_TW.lproj/Localizable.strings index 6168a7f66c..88a0bc3ec2 100644 --- a/DashWallet/zh_TW.lproj/Localizable.strings +++ b/DashWallet/zh_TW.lproj/Localizable.strings @@ -6212,20 +6212,38 @@ "“%@” has been registered. Send a contact request to the person who invited you?" = "「%@」已註冊。要向邀請您的人發送聯繫請求嗎?"; /* DashPay username registration recovery */ +"Act as you in this app without asking each time" = "Act as you in this app without asking each time"; +"App contract" = "App contract"; "Could not check username availability. Please try again." = "Could not check username availability. Please try again."; +"Could not disconnect: %@" = "Could not disconnect: %@"; "Finish username registration" = "Finish username registration"; "Identity is still loading" = "Identity is still loading"; "Loading identity…" = "Loading identity…"; "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused." = "Not enough identity credits to register this name. Use Top Up in My Profile, then try again. Your existing identity will be reused."; "Retry loading identity" = "Retry loading identity"; +"Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit." = "Spend up to %@ of your identity's credits on those actions. Each sign-in has its own limit."; "The active wallet or identity changed. Please try again." = "The active wallet or identity changed. Please try again."; +"The active wallet or identity changed. Try again." = "The active wallet or identity changed. Try again."; +"The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key." = "The app's key was added to your identity, but this wallet could not record it. Turn the app off in Connections to remove the key."; +"The app's keys on this identity are off, but this wallet could not check its other identities. Try again." = "The app's keys on this identity are off, but this wallet could not check its other identities. Try again."; +"The details of this sign-in changed while it was on screen. Check them and approve again." = "The details of this sign-in changed while it was on screen. Check them and approve again."; "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status." = "The name request succeeded for the original identity. Switch back to the original wallet and network to see its status."; +"This access ends after %d days, or when you disconnect the app." = "This access ends after %d days, or when you disconnect the app."; +"This app has a key on another identity of this wallet. Switch to that identity to turn it off." = "This app has a key on another identity of this wallet. Switch to that identity to turn it off."; +"This identity cannot take another key." = "This identity cannot take another key."; "This identity has no credits. Use Top Up in My Profile, then return to finish registration." = "This identity has no credits. Use Top Up in My Profile, then return to finish registration."; +"This sign-in request can no longer be approved. Ask the app for a new code." = "This sign-in request can no longer be approved. Ask the app for a new code."; "This username is no longer available. Please choose another one." = "This username is no longer available. Please choose another one."; +"This wallet cannot approve the scanned request (%@)." = "This wallet cannot approve the scanned request (%@)."; +"This wallet cannot record the key it would give the app, so the sign-in was not started." = "This wallet cannot record the key it would give the app, so the sign-in was not started."; +"This wallet could not check what this sign-in would give the app." = "This wallet could not check what this sign-in would give the app."; +"This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later." = "This wallet's copy of your identity is out of date, so it cannot confirm the app's key was turned off. Try again later."; "Username request completed" = "Username request completed"; "Wait for the wallet and network to finish loading, then retry. No registration has been sent." = "Wait for the wallet and network to finish loading, then retry. No registration has been sent."; +"Your Dash balance" = "Your Dash balance"; "Your identity is ready. Finish registering a username using its existing credits." = "Your identity is ready. Finish registering a username using its existing credits."; "Your identity is ready. Use its existing credits to register a username." = "Your identity is ready. Use its existing credits to register a username."; +"Your recovery phrase or wallet keys" = "Your recovery phrase or wallet keys"; "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again." = "“%@” was purchased successfully. Switch back to the original wallet and network to see it. Do not purchase it again."; "Couldn't move your wallet" = "Couldn't move your wallet"; "Do not delete the app. Try moving your wallet again, or contact support if the problem continues." = "Do not delete the app. Try moving your wallet again, or contact support if the problem continues."; diff --git a/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift index 25fee3c446..27f03143ed 100644 --- a/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift +++ b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift @@ -122,7 +122,8 @@ final class DashConnectDataSourceTests: XCTestCase { ) let dataSource = MockDashConnectDataSource(initial: [existing]) - _ = try await dataSource.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + _ = try await dataSource.approveLogin( + MockDashConnectDataSource.sampleLoginRequest, consent: MockDashConnectDataSource.sampleRequest) XCTAssertEqual(dataSource.connectionsSnapshot.count, 1) XCTAssertEqual(dataSource.connectionsSnapshot.first?.id, existing.id) diff --git a/DashWalletTests/DashConnect/DashConnectStoreTests.swift b/DashWalletTests/DashConnect/DashConnectStoreTests.swift index d0faac2dfb..92b923c2b2 100644 --- a/DashWalletTests/DashConnect/DashConnectStoreTests.swift +++ b/DashWalletTests/DashConnect/DashConnectStoreTests.swift @@ -32,6 +32,21 @@ final class DashConnectStoreTests: XCTestCase { XCTAssertEqual(store.load(), connections) } + func testRoundTripKeepsWhichLoginMadeAConnection() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + var oneQR = sampleConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .active, + updatedAt: Date(timeIntervalSince1970: 10)) + oneQR.loginKind = .oneQR + let twoQR = sampleConnection( + id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", status: .active, + updatedAt: Date(timeIntervalSince1970: 20)) + + store.save([oneQR, twoQR]) + + XCTAssertEqual(store.load().map(\.loginKind), [.oneQR, .twoQR]) + } + func testUnknownStatusDropsOnlyThatRow() throws { let store = makeStore(network: .testnet, walletIdHex: "wallet-a") let payload: [[String: Any]] = [ @@ -145,7 +160,8 @@ final class DashConnectStoreTests: XCTestCase { let store = makeStore(network: .testnet, walletIdHex: "wallet-a") let first = MockDashConnectDataSource(store: store) - _ = try await first.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + _ = try await first.approveLogin( + MockDashConnectDataSource.sampleLoginRequest, consent: MockDashConnectDataSource.sampleRequest) let second = MockDashConnectDataSource(store: store) @@ -157,9 +173,10 @@ final class DashConnectStoreTests: XCTestCase { func testRemovePersistsAcrossFreshMockInstances() async throws { let store = makeStore(network: .testnet, walletIdHex: "wallet-a") let first = MockDashConnectDataSource(store: store) - _ = try await first.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + _ = try await first.approveLogin( + MockDashConnectDataSource.sampleLoginRequest, consent: MockDashConnectDataSource.sampleRequest) - await first.remove(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + await first.remove(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", scope: nil) let second = MockDashConnectDataSource(store: store) XCTAssertEqual(second.connectionsSnapshot, []) @@ -178,7 +195,7 @@ final class DashConnectStoreTests: XCTestCase { store: store ) - await first.disconnect(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + try await first.disconnect(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", scope: nil) let second = MockDashConnectDataSource(store: store) XCTAssertEqual(second.connectionsSnapshot.count, 1) @@ -208,6 +225,41 @@ final class DashConnectStoreTests: XCTestCase { XCTAssertNotEqual(devnetA.storageKey, devnetB.storageKey) } + func testAPinnedStoreStaysWithItsWalletAndDevnetWhenTheActiveOnesChange() { + var walletIdHex: String? = "wallet-a" + var devnetName: String? = "moutai" + let store = UserDefaultsDashConnectStore( + defaults: defaults, + network: .devnet, + walletIdHexProvider: { walletIdHex }, + devnetNameProvider: { devnetName } + ) + let connection = sampleConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + status: .active, + updatedAt: Date(timeIntervalSince1970: 10)) + + let pinned = store.pinnedStore(toWalletIdHex: "wallet-a") + XCTAssertEqual(pinned.storageKey, store.storageKey) + + walletIdHex = "wallet-b" + devnetName = "paloma" + pinned.save([connection]) + + XCTAssertNotEqual(pinned.storageKey, store.storageKey) + XCTAssertEqual(pinned.load(), [connection]) + XCTAssertEqual(store.load(), [], "the wallet that became active got none of the rows") + walletIdHex = "wallet-a" + devnetName = "moutai" + XCTAssertEqual(store.load(), [connection]) + } + + func testAPinnedStoreNamesTheWalletItWasGivenNotTheActiveOne() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-b") + + XCTAssertEqual(store.pinnedStore(toWalletIdHex: "wallet-a").storageKey, "dashconnect.connections.v1.t.wallet-a") + } + func testUnnamedDevnetStoresNothing() { let store = makeStore(network: .devnet, walletIdHex: "wallet-a", devnetName: nil) store.save([sampleConnection( diff --git a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift index c0a724bcca..df1244e896 100644 --- a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift +++ b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift @@ -84,12 +84,16 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { // Explicit: the default follows the app's current network, and on // a fresh install that is mainnet, which the initializer rejects. supportedNetwork: .testnet, - store: TestDashConnectStore(initialConnections: [existing]) + store: TestDashConnectStore(initialConnections: [existing]), + activeWalletIdHex: { "wallet" } ) let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) - XCTAssertEqual(request.existingConnection, existing) + var found = request.existingConnection + XCTAssertNotNil(found?.scope, "a row from the published list names its scope") + found?.scope = nil + XCTAssertEqual(found, existing) XCTAssertEqual(request.appContractId, existing.id) } @@ -105,12 +109,16 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { // Explicit: the default follows the app's current network, and on // a fresh install that is mainnet, which the initializer rejects. supportedNetwork: .testnet, - store: TestDashConnectStore(initialConnections: [existing]) + store: TestDashConnectStore(initialConnections: [existing]), + activeWalletIdHex: { "wallet" } ) let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) - XCTAssertEqual(request.existingConnection, existing) + var found = request.existingConnection + XCTAssertNotNil(found?.scope, "a row from the published list names its scope") + found?.scope = nil + XCTAssertEqual(found, existing) } func testMakeConnectionRequestReturnsNilForUnknownContract() async throws { @@ -125,7 +133,8 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { // Explicit: the default follows the app's current network, and on // a fresh install that is mainnet, which the initializer rejects. supportedNetwork: .testnet, - store: TestDashConnectStore(initialConnections: [existing]) + store: TestDashConnectStore(initialConnections: [existing]), + activeWalletIdHex: { "wallet" } ) let request = try makeLoginRequest( contractId: Data(repeating: 0x44, count: 32), @@ -199,7 +208,7 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { XCTAssertEqual(tagless.disablePublicKeyIds, tagged.disablePublicKeyIds) let appParser = PlatformWalletDashConnectStateTransitionParser { bytes in - .identityUpdate(try wallet.parseIdentityUpdateTransition(bytes)) + Self.parsed(.identityUpdate(try wallet.parseIdentityUpdateTransition(bytes))) } guard case let .keyRegistration(appTransition) = try appParser.parse(taglessBytes) else { return XCTFail("Expected a key-registration transition") @@ -214,14 +223,9 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { let contractId = Data(repeating: 0x22, count: 32) let tokenId = Data(repeating: 0x23, count: 32) let parser = PlatformWalletDashConnectStateTransitionParser { _ in - .tokenPurchase(ManagedPlatformWallet.ParsedTokenPurchaseTransition( - ownerId: ownerId, - dataContractId: contractId, - tokenId: tokenId, - tokenContractPosition: 3, - tokenCount: 100, - totalAgreedPrice: 100_000_000 - )) + Self.parsed(.batch(.init(ownerId: ownerId, transitions: [ + Self.tokenRow(contractId: contractId, tokenId: tokenId) + ]))) } guard case let .tokenPurchase(purchase) = try parser.parse(Data([0x00])) else { @@ -237,19 +241,101 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { func testParserRejectsContractGroupKeysInsteadOfDroppingTheirRestriction() throws { let parser = PlatformWalletDashConnectStateTransitionParser { _ in - .identityUpdate(.init( + Self.parsed(.identityUpdate(.init( identityId: Data(repeating: 0x21, count: 32), addPublicKeys: [.init( keyId: 1, keyType: .ecdsaSecp256k1, purpose: .authentication, securityLevel: .high, pubkeyBytes: Data(repeating: 0x22, count: 33), contractBounds: .contractGroup(id: Data(repeating: 0x23, count: 32)))], - disablePublicKeyIds: [])) + disablePublicKeyIds: []))) } XCTAssertThrowsError(try parser.parse(Data([0]))) { XCTAssertEqual($0 as? DashConnectPlatformError, .keyRegistrationUnexpectedMutation) } } + func testParserKeepsTheLimitsAScannedKeyRegistrationCarries() throws { + let parser = PlatformWalletDashConnectStateTransitionParser { _ in + Self.parsed(.identityUpdate(.init( + identityId: Data(repeating: 0x21, count: 32), + addPublicKeys: [ + .init( + keyId: 1, keyType: .ecdsaHash160, purpose: .authentication, + securityLevel: .high, pubkeyBytes: Data(repeating: 0x22, count: 20), + totalBudget: 7, expiresAt: 9), + .init( + keyId: 2, keyType: .ecdsaSecp256k1, purpose: .encryption, + securityLevel: .medium, pubkeyBytes: Data(repeating: 0x23, count: 33)), + ], + disablePublicKeyIds: []))) + } + guard case let .keyRegistration(transition) = try parser.parse(Data([0])) else { + return XCTFail("Expected a key-registration transition") + } + XCTAssertEqual(transition.addPublicKeys.map(\.totalBudget), [7, nil]) + XCTAssertEqual(transition.addPublicKeys.map(\.expiresAt), [9, nil]) + } + + func testParserRefusesABatchThatIsNotALoneCompleteTokenPurchase() { + let ownerId = Data(repeating: 0x21, count: 32) + let contractId = Data(repeating: 0x22, count: 32) + let tokenId = Data(repeating: 0x23, count: 32) + let purchase = Self.tokenRow(contractId: contractId, tokenId: tokenId) + let refused: [(String, ManagedPlatformWallet.ParsedStateTransition)] = [ + ("two rows", Self.parsed(.batch(.init(ownerId: ownerId, transitions: [purchase, purchase])))), + ("no rows", Self.parsed(.batch(.init(ownerId: ownerId, transitions: [])))), + ("another token action", Self.parsed(.batch(.init(ownerId: ownerId, transitions: [ + Self.tokenRow(contractId: contractId, tokenId: tokenId, action: "Transfer") + ])))), + ("an incomplete row", Self.parsed(.batch(.init(ownerId: ownerId, transitions: [ + Self.tokenRow(contractId: contractId, tokenId: tokenId, complete: false) + ])))), + ("an incomplete transition", Self.parsed( + .batch(.init(ownerId: ownerId, transitions: [purchase])), complete: false)), + ("a missing price", Self.parsed(.batch(.init(ownerId: ownerId, transitions: [ + Self.tokenRow(contractId: contractId, tokenId: tokenId, amount: nil) + ])))), + ("a document row", Self.parsed(.batch(.init(ownerId: ownerId, transitions: [ + .document(.init( + dataContractId: contractId, documentType: "note", + documentId: Data(repeating: 0x24, count: 32), action: "Delete", + amount: nil, recipientId: nil, complete: true, details: nil)) + ])))), + ("another kind", Self.parsed(.other)), + ] + for (name, transition) in refused { + let parser = PlatformWalletDashConnectStateTransitionParser { _ in transition } + XCTAssertThrowsError(try parser.parse(Data([0])), name) { + XCTAssertEqual( + $0 as? DashConnectPlatformError, + .unsupportedStateTransition("TestKind"), + name) + } + } + } + + private static func parsed( + _ kind: ManagedPlatformWallet.ParsedStateTransitionKind, + complete: Bool = true + ) -> ManagedPlatformWallet.ParsedStateTransition { + .init( + kindName: "TestKind", ownerId: nil, isSigned: false, userFeeIncrease: 0, + complete: complete, serialized: Data(), details: nil, kind: kind) + } + + private static func tokenRow( + contractId: Data, + tokenId: Data, + action: String = "DirectPurchase", + amount: UInt64? = 100_000_000, + complete: Bool = true + ) -> ManagedPlatformWallet.ParsedBatchedTransition { + .token(.init( + dataContractId: contractId, tokenId: tokenId, tokenContractPosition: 3, + action: action, amount: amount, recipientId: nil, tokenCount: 100, + complete: complete, details: complete ? nil : "note")) + } + func testTokenPurchasePriceConvertsCreditsToDash() { // 1e11 credits = 1 DASH; 1e3 credits = 1 duff. XCTAssertEqual(Self.purchaseRequest(credits: 0).totalPriceDash, 0) @@ -842,6 +928,36 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { ) } + /// Turning off a one-QR connection that replaced a two-QR one disables + /// what is left of the two-QR login's keys as well. + func testADisconnectFindsTheLiveTwoQrLoginKeysByTheirDerivedPayload() { + let authentication = Data(repeating: 0x51, count: 20) + let encryption = Data(repeating: 0x52, count: 33) + func live(_ keys: [ManagedIdentity.IdentityPublicKeyInfo]) -> [UInt32] { + PlatformDashConnectDataSource.liveLoginKeyIds( + authenticationPublicKeyHash160: authentication, + encryptionPublicKey: encryption, + currentIdentityPublicKeys: keys) + } + let authenticationKey = makeIdentityPublicKeyInfo( + keyId: 100, purpose: .authentication, securityLevel: .high, keyType: .ecdsaHash160, data: authentication) + let encryptionKey = makeIdentityPublicKeyInfo( + keyId: 101, purpose: .encryption, securityLevel: .medium, keyType: .ecdsaSecp256k1, data: encryption) + let disabledAuthenticationKey = makeIdentityPublicKeyInfo( + keyId: 100, purpose: .authentication, securityLevel: .high, keyType: .ecdsaHash160, + data: authentication, disabledAt: 1) + // A one-QR session key: same kind as the two-QR authentication key, + // another payload. + let sessionKey = makeIdentityPublicKeyInfo( + keyId: 102, purpose: .authentication, securityLevel: .high, keyType: .ecdsaHash160, + data: Data(repeating: 0x53, count: 20)) + + XCTAssertEqual(live([encryptionKey, sessionKey, authenticationKey]), [100, 101]) + XCTAssertEqual(live([disabledAuthenticationKey, encryptionKey]), [101]) + XCTAssertEqual(live([disabledAuthenticationKey, sessionKey]), []) + XCTAssertEqual(live([]), []) + } + func testMissingKeyRegistrationKeysReturnsOnlyMissingEncryptionKey() throws { let chainKey = Data(repeating: 0x22, count: 32) let identityId = Data(repeating: 0x33, count: 32) @@ -1149,6 +1265,10 @@ private final class TestDashConnectStore: DashConnectStore { func save(_ connections: [DAppConnection]) { self.connections = connections } + + var scopeKey: String? { nil } + + func pinned(toWalletIdHex walletIdHex: String) -> any DashConnectStore { self } } /// What the approval sheet may offer after a failed token purchase. @@ -1222,6 +1342,141 @@ final class ConnectionsViewModelPurchaseTests: XCTestCase { /// /// `approvePendingTokenPurchase` works in an unstructured `Task` and the /// data source runs off the main actor, so there is no handle to await. + func testAFailedDisconnectIsReportedInsteadOfLookingLikeASuccess() async { + let spy = PurchaseApprovalSpy() + spy.disconnectFailure = PurchaseSpyError.refused + let viewModel = ConnectionsViewModel(dataSource: spy, featureUnavailable: false) + + viewModel.disconnect(Self.connection) + XCTAssertTrue(viewModel.isDisconnecting) + // A second tap while the first is still out must not start another. + viewModel.disconnect(Self.connection) + await waitUntil { !viewModel.isDisconnecting } + + XCTAssertEqual(spy.disconnectedIds, [Self.connection.id]) + XCTAssertEqual(viewModel.message?.kind, .error) + XCTAssertEqual(viewModel.message?.text.contains("spy refused"), true) + } + + func testASuccessfulDisconnectReportsNothing() async { + let spy = PurchaseApprovalSpy() + let viewModel = ConnectionsViewModel(dataSource: spy, featureUnavailable: false) + + var shown = Self.connection + shown.scope = "wallet-a t" + viewModel.disconnect(shown) + await waitUntil { !viewModel.isDisconnecting } + + XCTAssertEqual(spy.disconnectedIds, [Self.connection.id]) + XCTAssertEqual(spy.disconnectedScopes, ["wallet-a t"], "the row goes back with the scope it was shown for") + XCTAssertNil(viewModel.message) + } + + /// With the login unresolved the sheet's action asks again and approves + /// nothing. + func testAnUnresolvedLoginIsAskedForAgainInsteadOfApproved() async { + let unresolved = ConnectionRequest(loginRequest: MockDashConnectDataSource.sampleLoginRequest) + let resolved = Self.consent(identityId: "identity-a", terms: .current) + let spy = LoginApprovalSpy(shown: [unresolved, resolved]) + let viewModel = ConnectionsViewModel(dataSource: spy, featureUnavailable: false) + + viewModel.onQRScanned("login") + await waitUntil { viewModel.pendingRequest != nil } + XCTAssertEqual(viewModel.pendingRequest?.login, .unresolved) + viewModel.approvePendingRequest() + XCTAssertFalse(viewModel.isApproving, "an unresolved login cannot be approved") + viewModel.retryPendingRequest() + await waitUntil { !viewModel.isApproving } + + XCTAssertEqual(viewModel.pendingRequest, resolved) + XCTAssertEqual(spy.approvedConsents, []) + XCTAssertNil(viewModel.approveError) + } + + func testApprovingALoginPassesTheRequestAsTheSheetShowedIt() async { + let shown = Self.consent(identityId: "identity-a", terms: .current) + let spy = LoginApprovalSpy(shown: [shown]) + let viewModel = ConnectionsViewModel(dataSource: spy, featureUnavailable: false) + + viewModel.onQRScanned("login") + await waitUntil { viewModel.pendingRequest != nil } + viewModel.approvePendingRequest() + await waitUntil { viewModel.pendingRequest == nil } + + XCTAssertEqual(spy.approvedConsents, [shown]) + XCTAssertNil(viewModel.approveError) + } + + /// Another identity became active, or the network answers with the other + /// login, while the sheet was up: nothing is approved on the old sheet, + /// and the user decides again on what is true now. + func testAnApprovalThatIsOutOfDateShowsTheRequestAgainAsItStands() async { + let shown = Self.consent(identityId: "identity-a", terms: nil) + let current = Self.consent(identityId: "identity-b", terms: .current) + let spy = LoginApprovalSpy(shown: [shown, current]) + spy.approveFailures = [DashConnectPlatformError.approvalOutOfDate] + let viewModel = ConnectionsViewModel(dataSource: spy, featureUnavailable: false) + + viewModel.onQRScanned("login") + await waitUntil { viewModel.pendingRequest != nil } + viewModel.approvePendingRequest() + await waitUntil { !viewModel.isApproving } + + XCTAssertEqual(viewModel.pendingRequest, current) + XCTAssertEqual(viewModel.approveError, DashConnectPlatformError.approvalOutOfDate.localizedDescription) + + viewModel.approvePendingRequest() + await waitUntil { viewModel.pendingRequest == nil } + + XCTAssertEqual(spy.approvedConsents, [shown, current]) + XCTAssertNil(viewModel.approveError) + } + + /// The wallet could not check which login the network gets at approve + /// time: the sheet is rebuilt. When it still cannot tell, the sheet says + /// so itself and no error is added; when it can again, a note says why + /// nothing was approved. + func testAnApprovalThatCouldNotCheckTheLoginRebuildsTheSheet() async { + let shown = Self.consent(identityId: "identity-a", terms: .current) + var unresolved = shown + unresolved.login = .unresolved + unresolved.unresolvedLoginReason = "no nodes" + let spy = LoginApprovalSpy(shown: [shown, unresolved, shown]) + spy.approveFailures = [DashConnectPlatformError.loginUnresolved, DashConnectPlatformError.loginUnresolved] + let viewModel = ConnectionsViewModel(dataSource: spy, featureUnavailable: false) + + viewModel.onQRScanned("login") + await waitUntil { viewModel.pendingRequest != nil } + viewModel.approvePendingRequest() + await waitUntil { !viewModel.isApproving } + + XCTAssertEqual(viewModel.pendingRequest, unresolved) + XCTAssertNil(viewModel.approveError, "the sheet states the unresolved login itself") + + viewModel.retryPendingRequest() + await waitUntil { viewModel.pendingRequest == shown } + viewModel.approvePendingRequest() + await waitUntil { !viewModel.isApproving } + + XCTAssertEqual(viewModel.pendingRequest, shown) + XCTAssertEqual(viewModel.approveError, DashConnectPlatformError.loginUnresolved.localizedDescription) + } + + private static func consent(identityId: String, terms: AppConnect.SessionKeyTerms?) -> ConnectionRequest { + ConnectionRequest( + loginRequest: MockDashConnectDataSource.sampleLoginRequest, + appLabel: "App", + appUrl: "", + walletUsername: nil, + walletIdentityId: identityId, + existingConnection: nil, + login: terms.map(AppConnect.Login.oneQR) ?? .twoQR) + } + + private static let connection = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", name: "App", url: "", + status: .active, updatedAt: Date(timeIntervalSince1970: 1_000)) + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async { let deadline = Date().addingTimeInterval(timeout) while !condition(), Date() < deadline { @@ -1308,7 +1563,7 @@ private final class PurchaseApprovalSpy: DashConnectDataSource { return ConnectionRequest(loginRequest: loginRequest) } - func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { + func approveLogin(_ request: DashKeyRequest, consent: ConnectionRequest) async throws -> DAppConnection { throw PurchaseSpyError.unsupported } @@ -1316,15 +1571,98 @@ private final class PurchaseApprovalSpy: DashConnectDataSource { throw PurchaseSpyError.unsupported } - func disconnect(id: String) async { - XCTFail("disconnect is not part of the purchase approval flow") + private var storedDisconnectFailure: Error? + private var storedDisconnectedIds: [String] = [] + private var storedDisconnectedScopes: [String?] = [] + + var disconnectFailure: Error? { + get { lock.withLock { storedDisconnectFailure } } + set { lock.withLock { storedDisconnectFailure = newValue } } + } + + var disconnectedIds: [String] { + lock.withLock { storedDisconnectedIds } + } + + var disconnectedScopes: [String?] { + lock.withLock { storedDisconnectedScopes } + } + + func disconnect(id: String, scope: String?) async throws { + let failure = lock.withLock { () -> Error? in + storedDisconnectedIds.append(id) + storedDisconnectedScopes.append(scope) + return storedDisconnectFailure + } + if let failure { throw failure } } - func remove(id: String) async { + func remove(id: String, scope: String?) async { XCTFail("remove is not part of the purchase approval flow") } } +/// A `DashConnectDataSource` for the login approval: every QR is the sample +/// login, each resolution of it hands out the next request in `shown` (the +/// last one from then on), and approvals are recorded with what they carried. +private final class LoginApprovalSpy: DashConnectDataSource { + /// Guards every stored property: the protocol's methods are nonisolated, + /// so they run off the main actor the test asserts from. + private let lock = NSLock() + private var shown: [ConnectionRequest] + private var storedApproveFailures: [Error] = [] + private var storedApprovedConsents: [ConnectionRequest] = [] + + init(shown: [ConnectionRequest]) { + self.shown = shown + } + + /// Thrown by the approvals in order, one each; later approvals succeed. + var approveFailures: [Error] { + get { lock.withLock { storedApproveFailures } } + set { lock.withLock { storedApproveFailures = newValue } } + } + + var approvedConsents: [ConnectionRequest] { + lock.withLock { storedApprovedConsents } + } + + var connections: AnyPublisher<[DAppConnection], Never> { + Just([DAppConnection]()).eraseToAnyPublisher() + } + + func parseQR(_ content: String) async throws -> DashConnectQr { + .login(MockDashConnectDataSource.sampleLoginRequest) + } + + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest { + lock.withLock { shown.count > 1 ? shown.removeFirst() : shown[0] } + } + + func approveLogin(_ request: DashKeyRequest, consent: ConnectionRequest) async throws -> DAppConnection { + let failure = lock.withLock { () -> Error? in + storedApprovedConsents.append(consent) + return storedApproveFailures.isEmpty ? nil : storedApproveFailures.removeFirst() + } + if let failure { throw failure } + return MockDashConnectDataSource.sample(.active) + } + + func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction { + throw PurchaseSpyError.unsupported + } + + func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws { + throw PurchaseSpyError.unsupported + } + + func disconnect(id: String, scope: String?) async throws { + throw PurchaseSpyError.unsupported + } + + func remove(id: String, scope: String?) async {} +} + private enum PurchaseSpyError: LocalizedError { case refused case unsupported @@ -1338,3 +1676,1086 @@ private enum PurchaseSpyError: LocalizedError { } } } + +final class AppConnectLoginTests: XCTestCase { + func testResponseLookupQueriesTheIndexInOrderWithBase64Hash() throws { + let hash = Data(repeating: 0xAB, count: 20) + let owner = Data(repeating: 0x01, count: 32) + + let clause = AppConnect.responseLookupWhereClause(appEphemeralPubKeyHash: hash, ownerIdentityId: owner) + + let parsed = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(clause.utf8)) as? [[String]]) + XCTAssertEqual(parsed, [ + ["appEphemeralPubKeyHash", "==", hash.base64EncodedString()], + ["$ownerId", "==", owner.toBase58String()], + ]) + } + + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "AppConnectLoginTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testOneQrLoginStartsAtProtocolVersion14() { + XCTAssertFalse(AppConnect.isAvailable(protocolVersion: 13)) + XCTAssertTrue(AppConnect.isAvailable(protocolVersion: 14)) + XCTAssertTrue(AppConnect.isAvailable(protocolVersion: 15)) + } + + func testRequestIdIsHash256OfTheAppEphemeralKey() { + // hash256 of the empty string, a published vector. + XCTAssertEqual( + AppConnect.requestId(appEphemeralPubKey: Data()).toHexString(), + "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456") + XCTAssertEqual(AppConnect.requestId(appEphemeralPubKey: Data(repeating: 0x02, count: 33)).count, 32) + } + + func testSessionKeyIsAbsentUntilItsHashIsOnTheIdentity() { + let other = Self.key(keyId: 4, data: Data(repeating: 0x22, count: 20)) + XCTAssertEqual(Self.sessionKeyState(keys: [other]), .absent) + // The same bytes under another key type are a different key. + let sameBytesOtherType = Self.key(keyId: 5, keyType: .ecdsaSecp256k1, data: Self.sessionKeyHash) + XCTAssertEqual(Self.sessionKeyState(keys: [sameBytesOtherType]), .absent) + } + + func testRegisteredSessionKeyOfTheAppIsUsableWithItsKeyId() { + XCTAssertEqual(Self.sessionKeyState(keys: [Self.sessionKey()]), .usable(keyId: 9)) + // Registered a moment ago by a clock that has since been set back. + let afterClockCorrection = Self.sessionKey(expiresAt: Self.nowMilliseconds + 3_600_000 + 60_000) + XCTAssertEqual(Self.sessionKeyState(keys: [afterClockCorrection]), .usable(keyId: 9)) + // Limits narrower than the approved terms are within them. + let narrower = Self.sessionKey( + totalBudget: Self.terms.budgetCredits - 1, expiresAt: Self.nowMilliseconds + 1) + XCTAssertEqual(Self.sessionKeyState(keys: [narrower]), .usable(keyId: 9)) + } + + func testDisabledExpiredOrMisregisteredSessionKeyIsUnusable() { + let latestExpiry = Int64(Self.terms.expiresAt(from: Self.now.addingTimeInterval(AppConnect.clockTolerance))) + let unusable: [(String, ManagedIdentity.IdentityPublicKeyInfo)] = [ + ("disabled", Self.sessionKey(disabledAt: 1)), + ("expired", Self.sessionKey(expiresAt: Self.nowMilliseconds)), + ("wrong level", Self.sessionKey(securityLevel: .critical)), + ("wrong purpose", Self.sessionKey(purpose: .transfer)), + ("no budget", Self.sessionKey(totalBudget: nil)), + ("no expiry", Self.sessionKey(expiresAt: nil)), + ("budget above the approved one", Self.sessionKey(totalBudget: Self.terms.budgetCredits + 1)), + ("expiry after the approved one", Self.sessionKey(expiresAt: latestExpiry + 1)), + ] + for (name, key) in unusable { + XCTAssertEqual(Self.sessionKeyState(keys: [key]), .unusable, name) + } + } + + /// The key is derived from the identity and the request's ephemeral key + /// alone, so a request that repeats another app's ephemeral key finds + /// that app's key on the identity. + func testARegisteredSessionKeyBoundToAnotherAppIsUnusable() { + let otherApp = Data(repeating: 0xB2, count: 32) + let boundElsewhere = [AppConnect.KeyBinding(keyId: 9, data: Self.sessionKeyHash, singleContractId: otherApp)] + XCTAssertEqual(Self.sessionKeyState(keys: [Self.sessionKey()], bindings: boundElsewhere), .unusable) + // A grant record does not outweigh a bound the store can read. + XCTAssertEqual( + Self.sessionKeyState(keys: [Self.sessionKey()], bindings: boundElsewhere, grantedKeyIds: [9]), + .unusable) + let unbound = [AppConnect.KeyBinding(keyId: 9, data: Self.sessionKeyHash, singleContractId: nil)] + XCTAssertEqual(Self.sessionKeyState(keys: [Self.sessionKey()], bindings: unbound), .unusable) + } + + func testARegisteredSessionKeyTheStoreCannotPlaceNeedsAGrantRecord() { + let unplaced: [[AppConnect.KeyBinding]] = [ + [], + // The stored row under this id is for a different key. + [AppConnect.KeyBinding(keyId: 9, data: Data([0xFF]), singleContractId: Self.app)], + [AppConnect.KeyBinding( + keyId: 9, data: Self.sessionKeyHash, singleContractId: nil, hasUnreadableBound: true)], + ] + for bindings in unplaced { + XCTAssertEqual(Self.sessionKeyState(keys: [Self.sessionKey()], bindings: bindings), .unusable) + XCTAssertEqual( + Self.sessionKeyState(keys: [Self.sessionKey()], bindings: bindings, grantedKeyIds: [8]), + .unusable) + XCTAssertEqual( + Self.sessionKeyState(keys: [Self.sessionKey()], bindings: bindings, grantedKeyIds: [9]), + .usable(keyId: 9)) + } + } + + func testTheOneQrLoginHasTermsAndTheTwoQrLoginHasNone() { + XCTAssertEqual(AppConnect.login(protocolVersion: 13), .twoQR) + XCTAssertEqual(AppConnect.login(protocolVersion: 14), .oneQR(.current)) + XCTAssertNil(AppConnect.Login.twoQR.sessionKeyTerms) + XCTAssertNil(AppConnect.Login.unresolved.sessionKeyTerms) + XCTAssertEqual(AppConnect.Login.oneQR(.current).sessionKeyTerms, .current) + // A request built without asking the network is not a two-QR one. + XCTAssertEqual(ConnectionRequest(loginRequest: MockDashConnectDataSource.sampleLoginRequest).login, .unresolved) + XCTAssertEqual(AppConnect.SessionKeyTerms.current.budgetCredits, AppConnect.sessionKeyBudgetCredits) + XCTAssertEqual(AppConnect.SessionKeyTerms.current.lifetime, AppConnect.sessionKeyLifetime) + } + + func testTheApprovalSheetNamesTheBudgetInDashAndTheLifetimeInDays() { + let terms = AppConnect.SessionKeyTerms(budgetCredits: 5_000_000_000, lifetime: 90 * 24 * 60 * 60) + XCTAssertEqual(terms.budgetText.filter { $0.isNumber || $0.isLetter }, "005DASH") + XCTAssertEqual(terms.lifetimeDays, 90) + } + + /// A response an earlier login deleted is published again when its + /// request comes back. If that publish never confirms, the record must + /// not say the response is gone: a disconnect would drop it and leave + /// the response on Platform with nothing pointing at it. + func testARepublishThatNeverConfirmsKeepsItsRecordThroughADisconnect() { + var republished = Self.grant(sessionKeyId: 9) + republished.responseDeleted = true + republished.markPublishStarted() + XCTAssertNil(republished.documentId) + XCTAssertTrue(republished.hasUnconfirmedResponse) + + var knownGone = Self.grant(sessionKeyId: 10) + knownGone.responseDeleted = true + var otherApp = Self.grant(sessionKeyId: 11) + otherApp.responseDeleted = true + var grants = [republished, knownGone, otherApp] + AppConnect.markKeysDisabled(in: &grants) { $0.sessionKeyId != 11 } + + XCTAssertEqual(grants.map(\.sessionKeyId), [9, 11]) + XCTAssertEqual(grants.map(\.keyDisabled), [true, nil]) + XCTAssertTrue(grants[0].hasUnconfirmedResponse, "the disconnect still has a response to look for") + } + + func testNextKeyIdFollowsTheHighestKeyOnTheIdentity() { + XCTAssertEqual(AppConnect.nextKeyId(currentIdentityPublicKeys: []), 1) + XCTAssertEqual( + AppConnect.nextKeyId(currentIdentityPublicKeys: [ + Self.key(keyId: 0, data: Data([1])), + Self.key(keyId: 7, data: Data([2]), disabledAt: 1), + Self.key(keyId: 3, data: Data([3])), + ]), + 8) + } + + func testThereIsNoNextKeyIdAfterTheLastOne() { + // A key id is a UInt32 the SDK carries as an Int32 bit pattern. + let last = Self.key(keyId: -1, data: Data([1])) + XCTAssertNil(AppConnect.nextKeyId(currentIdentityPublicKeys: [last])) + let disabledLast = Self.key(keyId: -1, data: Data([1]), disabledAt: 1) + XCTAssertNil(AppConnect.nextKeyId(currentIdentityPublicKeys: [Self.key(keyId: 3, data: Data([3])), disabledLast])) + XCTAssertEqual( + AppConnect.nextKeyId(currentIdentityPublicKeys: [Self.key(keyId: -2, data: Data([2]))]), + .max) + } + + func testDisconnectDisablesOnlyTheLiveSessionKeysItGranted() { + let grants = [9, 11, 12, 13].map { Self.grant(sessionKeyId: $0) } + let keys = [ + Self.key(keyId: 0, securityLevel: .master, keyType: .ecdsaSecp256k1, data: Data([0])), + Self.key(keyId: 9, data: Data([9])), + // Not granted by these responses. + Self.key(keyId: 10, data: Data([10])), + // Already disabled. + Self.key(keyId: 11, data: Data([11]), disabledAt: 1), + // The id now belongs to a key this login would never register. + Self.key(keyId: 12, securityLevel: .critical, data: Data([12])), + // 13 is not on the identity at all. + Self.key(keyId: 14, data: Data([14])), + ] + let app = Data(repeating: 0xA1, count: 32) + XCTAssertEqual( + AppConnect.sessionKeyIdsToDisable( + for: grants, appContractId: app, bindings: [], currentIdentityPublicKeys: keys), + [9]) + XCTAssertEqual( + AppConnect.sessionKeyIdsToDisable( + for: [], appContractId: app, bindings: [], currentIdentityPublicKeys: keys), + []) + } + + func testDisconnectAlsoDisablesLiveKeysBoundToTheAppThatHaveNoGrantRecord() { + let app = Data(repeating: 0xA1, count: 32) + let otherApp = Data(repeating: 0xB2, count: 32) + let keys = [ + Self.key(keyId: 6, data: Data([6]), totalBudget: 5, expiresAt: 9_999), + Self.key(keyId: 7, data: Data([7]), totalBudget: 5, expiresAt: 9_999), + Self.key(keyId: 8, data: Data([8]), disabledAt: 1), + Self.key(keyId: 9, data: Data([9]), totalBudget: 5, expiresAt: 9_999), + Self.key(keyId: 10, data: Data([10]), totalBudget: 5, expiresAt: 9_999), + Self.key(keyId: 11, data: Data([11]), totalBudget: 5, expiresAt: 9_999), + // Bound to the app and of the same kind, but without limits and + // without a grant record: not a key this login registered. + Self.key(keyId: 13, data: Data([13])), + // Bound to the app, but not a key this login registers. + Self.key( + keyId: 12, purpose: .encryption, securityLevel: .medium, keyType: .ecdsaSecp256k1, + data: Data([12]), totalBudget: 5, expiresAt: 9_999), + // Bound to the app with one limit only. + Self.key(keyId: 14, data: Data([14]), expiresAt: 9_999), + ] + let bindings = [ + AppConnect.KeyBinding(keyId: 6, data: Data([6]), singleContractId: app), + AppConnect.KeyBinding(keyId: 7, data: Data([7]), singleContractId: app), + AppConnect.KeyBinding(keyId: 8, data: Data([8]), singleContractId: app), + AppConnect.KeyBinding(keyId: 9, data: Data([9]), singleContractId: app), + AppConnect.KeyBinding(keyId: 10, data: Data([10]), singleContractId: otherApp), + // The stored row under this id is for a different key. + AppConnect.KeyBinding(keyId: 11, data: Data([0xFF]), singleContractId: app), + AppConnect.KeyBinding(keyId: 12, data: Data([12]), singleContractId: app), + AppConnect.KeyBinding(keyId: 13, data: Data([13]), singleContractId: app), + AppConnect.KeyBinding(keyId: 14, data: Data([14]), singleContractId: app), + ] + XCTAssertEqual( + AppConnect.sessionKeyIdsToDisable( + for: [Self.grant(sessionKeyId: 9)], + appContractId: app, + bindings: bindings, + currentIdentityPublicKeys: keys), + [6, 7, 9]) + XCTAssertEqual( + AppConnect.sessionKeyIdsToDisable( + for: [], appContractId: otherApp, bindings: bindings, currentIdentityPublicKeys: keys), + [10]) + // Key 7 is the two-QR login's authentication key, given limits by + // its `dash-st:` registration: it stays unless a grant names it. + XCTAssertEqual( + AppConnect.sessionKeyIdsToDisable( + for: [Self.grant(sessionKeyId: 9)], + appContractId: app, + bindings: bindings, + currentIdentityPublicKeys: keys, + twoQRAuthenticationKeyHash160: Data([7])), + [6, 9]) + } + + /// Keys of the app on an identity that is not the active one are + /// recognised from the wallet's store: no grant record names them. + func testTheStoreShowsAnAppKeyHeldOnAnotherIdentityWhileItIsLive() { + let app = Data(repeating: 0xA1, count: 32) + let authentication = Data(repeating: 0x51, count: 20) + let encryption = Data(repeating: 0x52, count: 33) + func holdsTwoQR(_ bindings: [AppConnect.KeyBinding]) -> Bool { + AppConnect.holdsLiveTwoQRLoginKey( + authenticationPublicKeyHash160: authentication, encryptionPublicKey: encryption, bindings: bindings) + } + func authenticationKey(data: Data = authentication, isDisabled: Bool = false) -> AppConnect.KeyBinding { + AppConnect.KeyBinding( + keyId: 3, data: data, singleContractId: app, isDisabled: isDisabled, + isTwoQRAuthenticationKeyShaped: true) + } + let encryptionKey = AppConnect.KeyBinding( + keyId: 4, data: encryption, singleContractId: nil, isTwoQREncryptionKeyShaped: true) + + XCTAssertTrue(holdsTwoQR([authenticationKey()])) + XCTAssertTrue(holdsTwoQR([authenticationKey(isDisabled: true), encryptionKey])) + XCTAssertFalse(holdsTwoQR([authenticationKey(isDisabled: true)])) + // Another app's key: same kind, another payload. + XCTAssertFalse(holdsTwoQR([authenticationKey(data: Data(repeating: 0x53, count: 20))])) + // The same bytes under another kind are not that key. + XCTAssertFalse(holdsTwoQR([AppConnect.KeyBinding(keyId: 5, data: authentication, singleContractId: app)])) + XCTAssertFalse(holdsTwoQR([])) + + let sessionKey = AppConnect.KeyBinding( + keyId: 6, data: Data([6]), singleContractId: app, isSessionKeyShaped: true) + var disabledSessionKey = sessionKey + disabledSessionKey.isDisabled = true + XCTAssertTrue(AppConnect.holdsLiveSessionKey(appContractId: app, bindings: [sessionKey])) + XCTAssertFalse(AppConnect.holdsLiveSessionKey(appContractId: app, bindings: [disabledSessionKey])) + XCTAssertFalse( + AppConnect.holdsLiveSessionKey(appContractId: Data(repeating: 0xB2, count: 32), bindings: [sessionKey])) + // The two-QR authentication key is bound to the app but carries no limits. + XCTAssertFalse(AppConnect.holdsLiveSessionKey(appContractId: app, bindings: [authenticationKey()])) + } + + func testOnlyAWholeSingleContractBoundNamesTheApp() { + let app = Data(repeating: 0xA1, count: 32) + XCTAssertEqual(AppConnect.singleContractId(boundsKind: 1, boundIds: [app]), app) + XCTAssertNil(AppConnect.singleContractId(boundsKind: 0, boundIds: nil)) + XCTAssertNil(AppConnect.singleContractId(boundsKind: 2, boundIds: [app])) + XCTAssertNil(AppConnect.singleContractId(boundsKind: 3, boundIds: [app])) + XCTAssertNil(AppConnect.singleContractId(boundsKind: 1, boundIds: [])) + XCTAssertNil(AppConnect.singleContractId(boundsKind: 1, boundIds: [Data([1, 2])])) + + for kind in [0, 2, 3] { + XCTAssertTrue(AppConnect.isReadableBound(boundsKind: kind, boundIds: kind == 0 ? nil : [app])) + } + XCTAssertTrue(AppConnect.isReadableBound(boundsKind: 1, boundIds: [app])) + XCTAssertFalse(AppConnect.isReadableBound(boundsKind: 1, boundIds: [Data([1, 2])])) + XCTAssertFalse(AppConnect.isReadableBound(boundsKind: 1, boundIds: nil)) + XCTAssertFalse(AppConnect.isReadableBound(boundsKind: 4, boundIds: [app])) + } + + func testAKeyBoundElsewhereOrNotAtAllIsNeitherDisabledNorUnknown() { + let app = Data(repeating: 0xA1, count: 32) + let keys = [4, 5, 6].map { + Self.key(keyId: Int32($0), data: Data([UInt8($0)]), totalBudget: 5, expiresAt: 9_999) + } + let bindings = [ + // A document-type bound and an unbounded key both store no single contract. + AppConnect.KeyBinding(keyId: 4, data: Data([4]), singleContractId: nil), + AppConnect.KeyBinding(keyId: 5, data: Data([5]), singleContractId: nil), + AppConnect.KeyBinding(keyId: 6, data: Data([6]), singleContractId: Data(repeating: 0xB2, count: 32)), + ] + XCTAssertEqual( + AppConnect.sessionKeyIdsToDisable( + for: [], appContractId: app, bindings: bindings, currentIdentityPublicKeys: keys), + []) + XCTAssertEqual( + AppConnect.sessionKeyIdsWithUnknownBinding(for: [], bindings: bindings, currentIdentityPublicKeys: keys), + []) + } + + func testAStoredLiveKeyOfTheAppMissingFromTheIdentityViewIsReported() { + let app = Data(repeating: 0xA1, count: 32) + let otherApp = Data(repeating: 0xB2, count: 32) + let keys = [Self.key(keyId: 6, data: Data([6]))] + let bindings = [ + AppConnect.KeyBinding(keyId: 6, data: Data([6]), singleContractId: app), + AppConnect.KeyBinding(keyId: 7, data: Data([7]), singleContractId: app), + AppConnect.KeyBinding(keyId: 8, data: Data([8]), singleContractId: app, isDisabled: true), + AppConnect.KeyBinding(keyId: 9, data: Data([9]), singleContractId: otherApp), + ] + XCTAssertEqual(AppConnect.boundKeyIdsMissing(from: keys, appContractId: app, bindings: bindings), [7]) + } + + func testALiveSessionKeyTheStoreCannotPlaceStopsADisconnect() { + let app = Data(repeating: 0xA1, count: 32) + let keys = [ + Self.key(keyId: 0, securityLevel: .master, keyType: .ecdsaSecp256k1, data: Data([0])), + Self.key(keyId: 6, data: Data([6])), + Self.key(keyId: 7, data: Data([7])), + Self.key(keyId: 8, data: Data([8]), disabledAt: 1), + Self.key(keyId: 9, data: Data([9])), + Self.key(keyId: 11, data: Data([11])), + ] + let bindings = [ + AppConnect.KeyBinding(keyId: 6, data: Data([6]), singleContractId: nil), + AppConnect.KeyBinding(keyId: 11, data: Data([0xFF]), singleContractId: app), + AppConnect.KeyBinding(keyId: 14, data: Data([14]), singleContractId: nil, hasUnreadableBound: true), + ] + // 0 and 8 cannot be session keys that sign; 6 is in the store; 9 has + // a grant record. 7 is unknown, so is 11 (its row is another key) and + // 14 (its stored bound cannot be read). + XCTAssertEqual( + AppConnect.sessionKeyIdsWithUnknownBinding( + for: [Self.grant(sessionKeyId: 9)], + bindings: bindings, + currentIdentityPublicKeys: keys + [Self.key(keyId: 14, data: Data([14]))]), + [7, 11, 14]) + } + + private static func grant(sessionKeyId: UInt32) -> AppConnectGrant { + AppConnectGrant( + identityId: "identity", appContractId: "app", documentId: "document-\(sessionKeyId)", + values: .init(appEphemeralPubKeyHash: "aa", walletEphemeralPubKey: "bb", encryptedPayload: "cc"), + sessionKeyId: sessionKeyId, totalBudget: nil, expiresAt: nil, + publishedAt: Date(timeIntervalSince1970: 1_000)) + } + + func testSessionKeyIsBoundToTheRequestedContractWithBothLimits() { + let hash = Data(repeating: 0x11, count: 20) + let contractId = Data(repeating: 0x33, count: 32) + let key = AppConnect.sessionIdentityPubkey( + keyId: 8, publicKeyHash160: hash, appContractId: contractId, totalBudget: 5, expiresAt: 6) + XCTAssertEqual(key.keyId, 8) + XCTAssertEqual(key.keyType, .ecdsaHash160) + XCTAssertEqual(key.purpose, .authentication) + XCTAssertEqual(key.securityLevel, .high) + XCTAssertEqual(key.pubkeyBytes, hash) + XCTAssertEqual(key.contractBounds, .singleContract(id: contractId)) + XCTAssertEqual(key.totalBudget, 5) + XCTAssertEqual(key.expiresAt, 6) + } + + func testExpiryIsTheLifetimeAfterNowInMilliseconds() { + XCTAssertEqual( + AppConnect.SessionKeyTerms(budgetCredits: 1, lifetime: 60).expiresAt(from: Date(timeIntervalSince1970: 1_000)), + 1_060_000) + } + + func testResponseCarriesTheSessionKeyOnlyTheAppCanDecrypt() throws { + let sessionPrivateKey = Data(repeating: 0x5a, count: 32) + let appEphemeralPrivateKey = Data(repeating: 0x07, count: 32) + let appEphemeralPubKey = try Secp256k1.compressedPublicKey(privateKey: appEphemeralPrivateKey) + let walletEphemeralPrivateKeyBytes = Data(repeating: 0x09, count: 32) + var walletEphemeralPrivateKey = walletEphemeralPrivateKeyBytes + + let values = AppConnect.ResponseValues(try AppConnect.seal( + sessionPrivateKey, + appEphemeralPubKey: appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey)) + + XCTAssertEqual(walletEphemeralPrivateKey, Data(repeating: 0, count: 32), "the ephemeral key is wiped") + XCTAssertEqual( + values.appEphemeralPubKeyHash, + try KeyExchangeCrypto.hash160(appEphemeralPubKey).toHexString()) + let walletEphemeralPubKey = try Secp256k1.compressedPublicKey(privateKey: walletEphemeralPrivateKeyBytes) + XCTAssertEqual(values.walletEphemeralPubKey, walletEphemeralPubKey.toHexString()) + // 28-byte envelope plus one 32-byte key: the contract's minimum. + XCTAssertEqual(values.encryptedPayload.count, 60 * 2) + + let payload = try XCTUnwrap(Self.data(hex: values.encryptedPayload)) + // The app's side of the exchange: its private key with the wallet's + // public key. ECDH is symmetric, so the helper's parameters take the + // opposite pair to the one that encrypted. + XCTAssertEqual( + try KeyExchangeCrypto.decryptLoginKey( + payload, + walletEphemeralPriv: appEphemeralPrivateKey, + appEphemeralPub: walletEphemeralPubKey), + sessionPrivateKey) + } + + func testPropertiesJsonHoldsExactlyTheThreeContractFields() throws { + let values = AppConnect.ResponseValues( + appEphemeralPubKeyHash: "aa", walletEphemeralPubKey: "bb", encryptedPayload: "cc") + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(try values.propertiesJSON().utf8)) as? [String: String]) + XCTAssertEqual(object, [ + "appEphemeralPubKeyHash": "aa", + "walletEphemeralPubKey": "bb", + "encryptedPayload": "cc", + ]) + } + + func testAGrantWrittenBeforeTheDeleteFlagsExistedStillLoads() throws { + // The shape the first device run stored: no `responseDeleted`, no + // `keyDisabled`. Losing it would leave that key impossible to find. + let stored = """ + [{"identityId":"identity","appContractId":"app","documentId":"document", + "values":{"appEphemeralPubKeyHash":"aa","walletEphemeralPubKey":"bb","encryptedPayload":"cc"}, + "sessionKeyId":6,"totalBudget":5000000000,"expiresAt":1799315762000,"publishedAt":1791539765792}] + """ + defaults.set(Data(stored.utf8), forKey: "scope.app-connect-responses.v1") + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { "scope" } + + let grant = try XCTUnwrap(store.load().first) + XCTAssertEqual(grant.sessionKeyId, 6) + XCTAssertEqual(grant.documentId, "document") + XCTAssertNil(grant.responseDeleted) + XCTAssertNil(grant.keyDisabled) + XCTAssertTrue(grant.hasDeletableResponse) + } + + func testASavedGrantIsLoadedWithTheSameKey() { + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { "scope" } + let grant = AppConnectGrant( + identityId: "identity", appContractId: "app", documentId: nil, + values: .init(appEphemeralPubKeyHash: "aa", walletEphemeralPubKey: "bb", encryptedPayload: "cc"), + sessionKeyId: 6, totalBudget: nil, expiresAt: nil, + publishedAt: Date(timeIntervalSince1970: 1_791_539_765.792_813)) + store.save([grant]) + + XCTAssertEqual(store.load().first?.key, grant.key) + } + + func testAStoreWithoutAScopeCannotPersist() { + XCTAssertTrue( + UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { "scope" }.canPersist) + XCTAssertFalse( + UserDefaultsAppConnectResponseStore(defaults: defaults, network: .devnet) { nil }.canPersist) + } + + func testResponseValuesAreReadBackFromNumbersBase64AndHex() { + let hash = Data(repeating: 0xAB, count: 20) + let walletKey = Data([0x02] + [UInt8](repeating: 0x11, count: 32)) + let payload = Data(repeating: 0x22, count: 60) + let expected = AppConnect.ResponseValues( + appEphemeralPubKeyHash: hash.toHexString(), + walletEphemeralPubKey: walletKey.toHexString(), + encryptedPayload: payload.toHexString()) + let encodings: [(Data) -> Any] = [ + { $0.map(Int.init) }, { $0.base64EncodedString() }, { $0.toHexString() }, + ] + for encode in encodings { + let document: [String: Any] = [ + "$id": "id", + "appEphemeralPubKeyHash": encode(hash), + "walletEphemeralPubKey": encode(walletKey), + "encryptedPayload": encode(payload), + ] + XCTAssertEqual( + AppConnect.responseValues(fromDocument: document, appEphemeralPubKeyHash: hash), expected) + XCTAssertNil( + AppConnect.responseValues( + fromDocument: document, appEphemeralPubKeyHash: Data(repeating: 0xCD, count: 20))) + } + XCTAssertNil(AppConnect.responseValues(fromDocument: ["$id": "id"], appEphemeralPubKeyHash: hash)) + } + + func testAnUnconfirmedResponseIsNeitherDeletableNorKnownGone() { + var grant = Self.grant(sessionKeyId: 6) + grant.documentId = nil + XCTAssertTrue(grant.hasUnconfirmedResponse) + XCTAssertFalse(grant.hasDeletableResponse) + + grant.responseDeleted = true + XCTAssertFalse(grant.hasUnconfirmedResponse) + } + + func testARowThisBuildCannotReadIsKeptWhenTheStoreIsSaved() throws { + let stored = """ + [{"somethingNewer":true,"sessionKeyId":4}, + {"identityId":"identity","appContractId":"app","documentId":"document", + "values":{"appEphemeralPubKeyHash":"aa","walletEphemeralPubKey":"bb","encryptedPayload":"cc"}, + "sessionKeyId":6,"publishedAt":1791539765792}] + """ + defaults.set(Data(stored.utf8), forKey: "scope.app-connect-responses.v1") + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { "scope" } + + XCTAssertEqual(store.load().map(\.sessionKeyId), [6]) + store.save(store.load() + [Self.grant(sessionKeyId: 7)]) + XCTAssertEqual(store.load().map(\.sessionKeyId), [6, 7]) + + let rows = try XCTUnwrap( + JSONSerialization.jsonObject( + with: try XCTUnwrap(defaults.data(forKey: "scope.app-connect-responses.v1"))) as? [[String: Any]]) + XCTAssertEqual(rows.filter { $0["somethingNewer"] as? Bool == true }.count, 1) + + // Clearing the readable grants must not clear the unreadable row. + store.save([]) + XCTAssertNotNil(defaults.data(forKey: "scope.app-connect-responses.v1")) + } + + func testAStoredValueThatIsNotARowListIsNeverReplaced() { + let suiteName = "AppConnectResponseStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = "scope.app-connect-responses.v1" + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { "scope" } + + XCTAssertTrue(store.canPersist) + for stored in [Data(#"{"rows":[]}"#.utf8), Data([0xFF, 0x00])] { + defaults.set(stored, forKey: key) + // Nothing new would be kept, so a login must not start. + XCTAssertFalse(store.canPersist) + XCTAssertEqual(store.load().count, 0) + store.save([Self.grant(sessionKeyId: 7)]) + XCTAssertEqual(defaults.data(forKey: key), stored) + store.save([]) + XCTAssertEqual(defaults.data(forKey: key), stored) + } + // A value that is not data at all is left alone the same way. + let others: [NSObject] = ["rows" as NSString, ["rows": NSArray()] as NSDictionary, 7 as NSNumber] + for stored in others { + defaults.set(stored, forKey: key) + XCTAssertFalse(store.canPersist) + XCTAssertEqual(store.load().count, 0) + store.save([Self.grant(sessionKeyId: 7)]) + XCTAssertEqual(defaults.object(forKey: key) as? NSObject, stored) + store.save([]) + XCTAssertEqual(defaults.object(forKey: key) as? NSObject, stored) + } + defaults.removeObject(forKey: key) + XCTAssertTrue(store.canPersist) + } + + func testAGrantedKeyMissingFromTheIdentityViewIsReportedNotAssumedGone() { + let grants = [9, 13].map { Self.grant(sessionKeyId: $0) } + let keys = [Self.key(keyId: 9, data: Data([9]), disabledAt: 1)] + XCTAssertEqual(AppConnect.sessionKeyIdsMissing(from: keys, for: grants), [13]) + XCTAssertEqual(AppConnect.sessionKeyIdsMissing(from: keys, for: [grants[0]]), []) + } + + func testAGrantHasNothingToDeleteBeforeItIsPublishedOrAfterItsResponseIsGone() { + var grant = Self.grant(sessionKeyId: 6) + XCTAssertTrue(grant.hasDeletableResponse) + grant.responseDeleted = true + XCTAssertFalse(grant.hasDeletableResponse) + grant.responseDeleted = nil + grant.documentId = nil + XCTAssertFalse(grant.hasDeletableResponse) + } + + func testPublishedResponsesSurviveAReloadAndAreScoped() { + let response = AppConnectGrant( + identityId: "identity", appContractId: "app", documentId: "document", + values: .init(appEphemeralPubKeyHash: "aa", walletEphemeralPubKey: "bb", encryptedPayload: "cc"), + sessionKeyId: 8, totalBudget: 5, expiresAt: 6, + publishedAt: Date(timeIntervalSince1970: 1_000)) + var scope: String? = "scope-a" + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { scope } + + store.save([response]) + XCTAssertEqual(store.load(), [response]) + + scope = "scope-b" + XCTAssertEqual(store.load(), [], "another wallet does not see the records") + scope = nil + store.save([response]) + XCTAssertEqual(store.load(), [], "nothing is stored without a wallet scope") + + scope = "scope-a" + store.save([]) + XCTAssertEqual(store.load(), []) + } + + func testPinnedGrantRecordsStayWithTheirWalletWhenTheActiveOneChanges() { + var walletIdHex: String? = "wallet-b" + let connections = UserDefaultsDashConnectStore( + defaults: defaults, + network: .testnet, + walletIdHexProvider: { walletIdHex }, + devnetNameProvider: { nil } + ) + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, scope: .connections(connections)) + let grant = Self.grant(sessionKeyId: 6) + + // Pinned for the wallet the operation runs for, which the active + // one has already stopped being. + let pinned = store.pinned(toWalletIdHex: "wallet-a") + XCTAssertTrue(pinned.canPersist) + pinned.save([grant]) + + XCTAssertEqual(pinned.load(), [grant]) + XCTAssertEqual(store.load(), [], "the active wallet got none of the records") + walletIdHex = "wallet-a" + XCTAssertEqual(store.load(), [grant]) + } + + func testDisconnectOfARowFromAnotherWalletIsRefusedAndTheListMovesToTheActiveWallet() async { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row]) + let shown = Self.shownRows(dataSource) + let other = Self.twoQRRow(id: "7UaqHGBJBbRLJ4fUWS45cnud8PPUugJWoGTt1SKwHJ2P", status: .approved) + active.idHex = "wallet-b" + connections.save([other]) + + await Self.expectWalletChanged { try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) } + + XCTAssertEqual(Self.shownRows(dataSource).map(\.id), [other.id], "the list shows the wallet that is active") + XCTAssertEqual(connections.load(), [other]) + active.idHex = "wallet-a" + XCTAssertEqual(connections.load(), [row], "the other wallet's row is untouched") + } + + func testDisconnectIsRefusedWhenTheNewlyActiveWalletHasARowForTheSameApp() async { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row]) + let shown = Self.shownRows(dataSource) + active.idHex = "wallet-b" + connections.save([row]) + + await Self.expectWalletChanged { try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) } + + XCTAssertEqual(connections.load().map(\.status), [.active]) + } + + func testDisconnectFromAnOldListIsRefusedAfterTheListAlreadyMovedToTheNewWallet() async throws { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let center = NotificationCenter() + let (dataSource, connections) = makeScopedDataSource( + active: active, rows: [row], notificationCenter: center) + // What the screen still holds when the tap lands. + let shown = Self.shownRows(dataSource) + active.idHex = "wallet-b" + connections.save([row]) + let moved = expectation(description: "the active wallet's rows are published") + moved.assertForOverFulfill = false + let subscription = dataSource.connections.sink { rows in + if rows.first?.scope != nil, rows.first?.scope != shown[0].scope { moved.fulfill() } + } + defer { subscription.cancel() } + center.post(name: SwiftDashSDKWalletState.activeWalletDidChangeNotification, object: nil) + await fulfillment(of: [moved], timeout: 2) + + await Self.expectWalletChanged { try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) } + await dataSource.remove(id: shown[0].id, scope: shown[0].scope) + + XCTAssertEqual(connections.load(), [row], "the new wallet's row for the same app is untouched") + // The row the new list shows is this wallet's own. + let current = Self.shownRows(dataSource) + try await dataSource.disconnect(id: current[0].id, scope: current[0].scope) + XCTAssertEqual(connections.load().map(\.status), [.approved]) + } + + func testRemoveDoesNothingForARowPublishedForAnotherWallet() async { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row]) + let shown = Self.shownRows(dataSource) + active.idHex = "wallet-b" + connections.save([row]) + + await dataSource.remove(id: shown[0].id, scope: shown[0].scope) + XCTAssertEqual(connections.load(), [row]) + + let current = Self.shownRows(dataSource) + await dataSource.remove(id: current[0].id, scope: current[0].scope) + XCTAssertEqual(connections.load(), [], "the list is this wallet's now") + } + + func testDisconnectOfATwoQRRowTurnsItOffInTheActiveWallet() async throws { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row]) + let shown = Self.shownRows(dataSource) + + try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) + + XCTAssertEqual(connections.load().map(\.status), [.approved]) + XCTAssertEqual(Self.shownRows(dataSource).map(\.status), [.approved]) + } + + func testARowShownForADevnetAtOtherNodesIsNotActedOnUnderTheSameName() async throws { + let active = ActiveWallet("wallet-a") + let endpoint = Endpoint("https://quorums.one.example") + let center = NotificationCenter() + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource( + active: active, rows: [row], notificationCenter: center, endpoint: endpoint) + let shown = Self.shownRows(dataSource) + + // The same wallet and devnet name, pointed at other nodes. + endpoint.url = "https://quorums.two.example" + + await Self.expectWalletChanged { try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) } + XCTAssertEqual(connections.load().map(\.status), [.active]) + let current = Self.shownRows(dataSource) + XCTAssertNotEqual(current[0].scope, shown[0].scope) + try await dataSource.disconnect(id: current[0].id, scope: current[0].scope) + XCTAssertEqual(connections.load().map(\.status), [.approved]) + } + + func testARowWithNoScopeIsNotActedOn() async { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row]) + + await Self.expectWalletChanged { try await dataSource.disconnect(id: row.id, scope: nil) } + await dataSource.remove(id: row.id, scope: nil) + + XCTAssertEqual(connections.load(), [row]) + } + + func testTheListMovesToTheActiveWalletWhenTheWalletChangeIsAnnounced() async { + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let center = NotificationCenter() + let (dataSource, connections) = makeScopedDataSource( + active: active, rows: [row], notificationCenter: center) + let other = Self.twoQRRow(id: "7UaqHGBJBbRLJ4fUWS45cnud8PPUugJWoGTt1SKwHJ2P", status: .approved) + active.idHex = "wallet-b" + connections.save([other]) + let moved = expectation(description: "the active wallet's rows are published") + let subscription = dataSource.connections.sink { rows in + if rows.map(\.id) == [other.id] { moved.fulfill() } + } + defer { subscription.cancel() } + + center.post( + name: SwiftDashSDKWalletState.activeWalletDidChangeNotification, object: nil) + + await fulfillment(of: [moved], timeout: 2) + } + + func testOperationsForOneWalletRunOneAtATimeInArrivalOrder() async { + let gate = DashConnectOperationGate() + let log = OperationLog() + let firstStarted = expectation(description: "the first operation is running") + let secondWaits = expectation(description: "the second operation waits") + let thirdWaits = expectation(description: "the third operation waits") + let release = AsyncStream.makeStream() + + let first = Task { + await gate.run(for: "wallet-a") { + await log.add("first starts") + firstStarted.fulfill() + for await _ in release.stream { break } + await log.add("first ends") + } + } + await fulfillment(of: [firstStarted], timeout: 2) + let second = Task { + await gate.run(for: "wallet-a", onWait: { secondWaits.fulfill() }) { await log.add("second runs") } + } + await fulfillment(of: [secondWaits], timeout: 2) + let third = Task { + await gate.run(for: "wallet-a", onWait: { thirdWaits.fulfill() }) { await log.add("third runs") } + } + await fulfillment(of: [thirdWaits], timeout: 2) + // Another wallet does not wait for this one. + await gate.run(for: "wallet-b") { await log.add("other wallet runs") } + release.continuation.yield() + await first.value + await second.value + await third.value + + let entries = await log.entries + XCTAssertEqual( + entries, ["first starts", "other wallet runs", "first ends", "second runs", "third runs"]) + } + + func testADisconnectWaitsForAnOperationAnotherDataSourceIsRunning() async throws { + let gate = DashConnectOperationGate() + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row], operationGate: gate) + let shown = Self.shownRows(dataSource) + let otherStarted = expectation(description: "the other operation is running") + let release = AsyncStream.makeStream() + // What another data source of the same wallet would be doing. + let other = Task { + await gate.run(for: "wallet-a") { + otherStarted.fulfill() + for await _ in release.stream { break } + // It wrote a row of its own meanwhile. + connections.save([row, Self.twoQRRow(id: "7UaqHGBJBbRLJ4fUWS45cnud8PPUugJWoGTt1SKwHJ2P", status: .active)]) + } + } + await fulfillment(of: [otherStarted], timeout: 2) + + let disconnect = Task { try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) } + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(connections.load().map(\.status), [.active], "the disconnect has not run yet") + + release.continuation.yield() + await other.value + try await disconnect.value + + let rows = connections.load() + XCTAssertEqual(rows.count, 2, "the row the other operation wrote is kept") + XCTAssertEqual(rows.first { $0.id == row.id }?.status, .approved) + } + + func testAnOperationDoesNotStartForAWalletThatIsNoLongerActiveWhenItsTurnComes() async { + let gate = DashConnectOperationGate() + let active = ActiveWallet("wallet-a") + let row = Self.twoQRRow(status: .active) + let (dataSource, connections) = makeScopedDataSource(active: active, rows: [row], operationGate: gate) + let shown = Self.shownRows(dataSource) + let otherStarted = expectation(description: "the other operation is running") + let release = AsyncStream.makeStream() + let other = Task { + await gate.run(for: "wallet-a") { + otherStarted.fulfill() + for await _ in release.stream { break } + } + } + await fulfillment(of: [otherStarted], timeout: 2) + + let disconnect = Task { + await Self.expectWalletChanged { try await dataSource.disconnect(id: shown[0].id, scope: shown[0].scope) } + } + try? await Task.sleep(nanoseconds: 100_000_000) + active.idHex = "wallet-b" + release.continuation.yield() + await other.value + await disconnect.value + + active.idHex = "wallet-a" + XCTAssertEqual(connections.load(), [row]) + } + + func testAFailedOperationHandsTheTurnOn() async { + let gate = DashConnectOperationGate() + + do { + try await gate.run(for: "wallet-a") { throw DashConnectPlatformError.noWallet } + XCTFail("the failure was swallowed") + } catch {} + let ran = await gate.run(for: "wallet-a") { true } + + XCTAssertTrue(ran) + } + + private actor OperationLog { + private(set) var entries: [String] = [] + func add(_ entry: String) { entries.append(entry) } + } + + func testAnApprovalIsForTheIdentityWalletAndNetworkItsRequestWasResolvedFor() { + let request = ConnectionRequest( + loginRequest: MockDashConnectDataSource.sampleLoginRequest, + appLabel: "App", + appUrl: "", + walletUsername: nil, + walletIdentityId: "identity-a", + existingConnection: nil, + login: .twoQR, + scope: "wallet-a devnet-one") + + XCTAssertTrue(request.wasResolved(forIdentityId: "identity-a", scope: "wallet-a devnet-one")) + XCTAssertFalse(request.wasResolved(forIdentityId: "identity-b", scope: "wallet-a devnet-one")) + XCTAssertFalse( + request.wasResolved(forIdentityId: "identity-a", scope: "wallet-a devnet-two"), + "the same wallet and identity on another devnet") + // A request that was not resolved against a wallet approves nothing. + var unscoped = request + unscoped.scope = nil + XCTAssertFalse(unscoped.wasResolved(forIdentityId: "identity-a", scope: "wallet-a devnet-one")) + } + + func testDisconnectWithNoActiveWalletIsRefused() async { + let active = ActiveWallet(nil) + let (dataSource, _) = makeScopedDataSource(active: active) + + do { + try await dataSource.disconnect(id: Self.twoQRRow(status: .active).id, scope: nil) + XCTFail("disconnected with no wallet") + } catch { + guard case DashConnectPlatformError.noWallet = error else { + return XCTFail("unexpected error: \(error)") + } + } + } + + /// The rows the data source publishes right now. + private static func shownRows(_ dataSource: PlatformDashConnectDataSource) -> [DAppConnection] { + var rows: [DAppConnection] = [] + let subscription = dataSource.connections.sink { rows = $0 } + subscription.cancel() + return rows + } + + private static func expectWalletChanged( + _ action: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + try await action() + XCTFail("a row of a wallet that is no longer active was acted on", file: file, line: line) + } catch { + guard case DashConnectPlatformError.walletChanged = error else { + return XCTFail("unexpected error: \(error)", file: file, line: line) + } + } + } + + private final class ActiveWallet { + var idHex: String? + init(_ idHex: String?) { self.idHex = idHex } + } + + private final class Endpoint { + var url: String? + init(_ url: String?) { self.url = url } + } + + /// `rows` are the active wallet's, stored before the data source loads + /// its list. + private func makeScopedDataSource( + active: ActiveWallet, + rows: [DAppConnection] = [], + notificationCenter: NotificationCenter = NotificationCenter(), + operationGate: DashConnectOperationGate = DashConnectOperationGate(), + endpoint: Endpoint = Endpoint(nil) + ) -> (PlatformDashConnectDataSource, UserDefaultsDashConnectStore) { + let connections = UserDefaultsDashConnectStore( + defaults: defaults, + network: .testnet, + walletIdHexProvider: { active.idHex }, + devnetNameProvider: { nil } + ) + connections.save(rows) + let dataSource = PlatformDashConnectDataSource( + supportedNetwork: .testnet, + store: connections, + responseStore: UserDefaultsAppConnectResponseStore(defaults: defaults, scope: .connections(connections)), + activeWalletIdHex: { active.idHex }, + networkEndpoint: { endpoint.url }, + notificationCenter: notificationCenter, + operationGate: operationGate + ) + return (dataSource, connections) + } + + private static func twoQRRow( + id: String = "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + status: ConnectionStatus + ) -> DAppConnection { + DAppConnection( + id: id, name: "Yappr", url: "yap.pr", status: status, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300)) + } + + func testAPinnedHandedInScopeDoesNotFollowItsProvider() { + var scope: String? = "scope-a" + let store = UserDefaultsAppConnectResponseStore(defaults: defaults, network: .testnet) { scope } + let grant = Self.grant(sessionKeyId: 6) + + let pinned = store.pinned(toWalletIdHex: "wallet-a") + scope = "scope-b" + pinned.save([grant]) + + XCTAssertEqual(store.load(), []) + scope = "scope-a" + XCTAssertEqual(store.load(), [grant]) + } + + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + private static let nowMilliseconds: Int64 = 1_800_000_000_000 + private static let app = Data(repeating: 0xA1, count: 32) + private static let sessionKeyHash = Data(repeating: 0x11, count: 20) + private static let terms = AppConnect.SessionKeyTerms(budgetCredits: 500, lifetime: 3_600) + + /// Key 9 as the one-QR login registers it, within `terms`. + private static func sessionKey( + purpose: KeyPurpose = .authentication, + securityLevel: SecurityLevel = .high, + disabledAt: Int64? = nil, + totalBudget: UInt64? = terms.budgetCredits, + expiresAt: Int64? = nowMilliseconds + 3_600_000 + ) -> ManagedIdentity.IdentityPublicKeyInfo { + key( + keyId: 9, purpose: purpose, securityLevel: securityLevel, data: sessionKeyHash, + disabledAt: disabledAt, totalBudget: totalBudget, expiresAt: expiresAt) + } + + /// The state of the session key for `app`; by default the store shows + /// key 9 bound to it. + private static func sessionKeyState( + keys: [ManagedIdentity.IdentityPublicKeyInfo], + bindings: [AppConnect.KeyBinding] = [ + AppConnect.KeyBinding(keyId: 9, data: sessionKeyHash, singleContractId: app), + ], + grantedKeyIds: Set = [] + ) -> AppConnect.SessionKeyState { + AppConnect.sessionKeyState( + publicKeyHash160: sessionKeyHash, + currentIdentityPublicKeys: keys, + appContractId: app, + bindings: bindings, + grantedKeyIds: grantedKeyIds, + terms: terms, + now: now) + } + + private static func key( + keyId: Int32, + purpose: KeyPurpose = .authentication, + securityLevel: SecurityLevel = .high, + keyType: KeyType = .ecdsaHash160, + data: Data, + disabledAt: Int64? = nil, + totalBudget: UInt64? = nil, + expiresAt: Int64? = nil + ) -> ManagedIdentity.IdentityPublicKeyInfo { + ManagedIdentity.IdentityPublicKeyInfo( + keyId: keyId, purpose: purpose, securityLevel: securityLevel, keyType: keyType, + readOnly: false, disabledAt: disabledAt, data: data, totalBudget: totalBudget, expiresAt: expiresAt) + } + + private static func data(hex: String) -> Data? { + var bytes = [UInt8]() + var index = hex.startIndex + while index < hex.endIndex { + guard let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex), + let byte = UInt8(hex[index ..< next], radix: 16) else { return nil } + bytes.append(byte) + index = next + } + return Data(bytes) + } +}