Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

1819 lines
81 KiB
Swift

import Foundation
import TelegramCore
import PasscodeCore
import WalletEngineFFI
@available(macOS 10.15, *)
func verifyWalletImportKey(
anchorPublicKey: Data,
signingPublicKey: Data,
accountState: () async throws -> WalletContext.WalletAccountState,
publicKey: () async throws -> Data
) async throws {
guard anchorPublicKey.count == 32, signingPublicKey.count == 32 else {
throw WalletContext.WalletError.publicKeyInvalid
}
do {
try Task.checkCancellation()
let currentKey: Data
switch try await accountState() {
case .active:
currentKey = try await publicKey()
guard currentKey.count == 32 else { throw WalletContext.WalletError.network }
case .undeployed:
currentKey = anchorPublicKey
case .unavailable:
throw WalletContext.WalletError.unavailable
}
try Task.checkCancellation()
guard currentKey == signingPublicKey else { throw WalletContext.WalletError.recoveryPhraseOutdated }
} catch is CancellationError {
throw CancellationError()
} catch let error as WalletContext.WalletError {
throw error
} catch {
throw WalletContext.WalletError.network
}
}
@available(macOS 10.15, *)
enum WalletImportCandidateDisposition: Equatable {
case promote
case discard
case retain
}
@available(macOS 10.15, *)
func walletImportCandidateDisposition(
sameAddress: Bool,
sameSigningKey: Bool,
hasPendingKeyRotation: Bool,
discardMismatch: Bool,
verify: () async throws -> Void,
validateRevision: () throws -> Void
) async throws -> WalletImportCandidateDisposition {
try validateRevision()
guard !hasPendingKeyRotation else { return .retain }
guard sameAddress else { return discardMismatch ? .discard : .retain }
do {
try await verify()
} catch WalletContext.WalletError.recoveryPhraseOutdated {
try validateRevision()
return discardMismatch ? .discard : .retain
}
try validateRevision()
return sameSigningKey ? .promote : .retain
}
@available(macOS 10.15, *)
struct WalletEngineActivation: @unchecked Sendable {
let snapshot: WalletSnapshot
let canSign: Bool
}
@available(macOS 10.15, *)
struct WalletEngineStagedWallet: Equatable, Sendable {
let recordId: String
let address: String
let publicKey: Data
let signingPublicKey: Data
let isPersisted: Bool
}
@available(macOS 10.15, *)
struct WalletEngineSendExecution: @unchecked Sendable {
let result: SendResult
let didRecreateClient: Bool
init(result: SendResult, didRecreateClient: Bool) {
self.result = result
self.didRecreateClient = didRecreateClient
}
}
@available(macOS 10.15, *)
enum WalletEngineKeyRotationResolution: Equatable, Sendable {
case none
case pending(operationId: String, retryAfterMilliseconds: UInt64?)
case confirmed(operationId: String)
case rolledBack(operationId: String, phase: SendPhase)
}
@available(macOS 10.15, *)
private enum WalletEngineKeyRotationChainState: Equatable {
case replacement
case previous
case different
}
@available(macOS 10.15, *)
actor WalletEngineRuntime {
private enum FfiPriority {
case background
case userInitiated
}
private enum FfiCancellation: Equatable {
case none
case sendPreview
case send
}
let storage: WalletEngineStorage
private var lastKnownBalance: Int64?
private let engine: TelegramEngine
private let logger: WalletLogger
private let platformHost: WalletEnginePlatformHost
private var statuslessHost: WalletEngineStatuslessHost
private let lifecycle: WalletLifecycle
private var client: WalletClient?
private var clientConfig: WalletClientConfig?
private var clientRevision: UInt64 = 0
private var descriptor: WalletDescriptor?
private var serverWalletIdentity: (address: String, publicKey: Data)?
private var serverStateRevision: UInt64 = 0
private var requiresWalletKeyReconciliation = false
private var transientReplacementDescriptor: WalletDescriptor?
private var ffiBusy = false
private var userInitiatedFfiWaiters: [CheckedContinuation<Void, Never>] = []
private var backgroundFfiWaiters: [CheckedContinuation<Void, Never>] = []
private var activeFfiOperation: (id: UUID, cancellation: FfiCancellation)?
init(engine: TelegramEngine, storage: WalletEngineStorage, logger: WalletLogger) {
self.storage = storage
self.engine = engine
self.logger = logger
self.platformHost = WalletEnginePlatformHost(storage: storage, logger: logger)
self.statuslessHost = WalletEngineStatuslessHost(engine: engine, logger: logger)
self.lifecycle = WalletLifecycle(platformHost: self.platformHost)
}
func activate(
serverAddress: String,
serverPublicKey: Data,
archivePreviousWallet: Bool = false,
serverStateRevision: UInt64? = nil
) async throws -> WalletEngineActivation {
let revision = serverStateRevision ?? self.serverStateRevision
return try await self.withFfi {
try await self.activateUnlocked(
serverAddress: serverAddress,
serverPublicKey: serverPublicKey,
archivePreviousWallet: archivePreviousWallet,
serverStateRevision: revision
)
}
}
func requireWalletKeyReconciliation(revision: UInt64) {
guard revision > self.serverStateRevision else { return }
self.requiresWalletKeyReconciliation = true
self.invalidateServerWalletIdentity(revision: revision)
}
func updateServerWalletIdentity(address: String, publicKey: Data, revision: UInt64) {
guard revision > self.serverStateRevision else { return }
self.serverStateRevision = revision
self.serverWalletIdentity = (address, publicKey)
}
func invalidateServerWalletIdentity(revision: UInt64) {
guard revision > self.serverStateRevision else { return }
self.serverStateRevision = revision
self.serverWalletIdentity = nil
}
private func adoptServerWalletIdentity(address: String, publicKey: Data, revision: UInt64) throws {
guard revision >= self.serverStateRevision else { throw CancellationError() }
self.serverStateRevision = revision
self.serverWalletIdentity = (address, publicKey)
}
func hasReplacementCandidate() async throws -> Bool {
try await self.storage.loadReplacementCandidate() != nil
}
func isPersistedReplacementCandidate(recordId: String) async throws -> Bool {
try await self.storage.loadReplacementCandidate()?.recordId == recordId
}
private func verifyImportKey(address: String, anchorPublicKey: Data, signingPublicKey: Data) async throws {
try await verifyWalletImportKey(
anchorPublicKey: anchorPublicKey,
signingPublicKey: signingPublicKey,
accountState: { try await self.statuslessHost.walletAccountState(address: address) },
publicKey: { try await self.statuslessHost.walletPublicKey(address: address) }
)
}
func verifyReplacement(recordId: String) async throws {
try await self.withFfi {
guard try await self.storage.loadKeyRotation() == nil else {
throw WalletContext.WalletError.operationInProgress
}
let revision = self.serverStateRevision
let record: WalletEngineDescriptorRecord
if let descriptor = self.transientReplacementDescriptor, descriptor.recordId == recordId {
record = WalletEngineDescriptorRecord(descriptor: descriptor)
} else if let candidate = try await self.storage.loadReplacementCandidate(), candidate.recordId == recordId {
record = candidate
} else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let signingPublicKey = try await self.signingPublicKey(for: record)
do {
try await self.verifyImportKey(address: record.address, anchorPublicKey: record.publicKey, signingPublicKey: signingPublicKey)
} catch {
guard self.serverStateRevision == revision else { throw CancellationError() }
throw error
}
guard self.serverStateRevision == revision else { throw CancellationError() }
}
}
func stageTransientReplacement(words: [String]) async throws -> WalletEngineStagedWallet {
let recordId = UUID().uuidString.lowercased()
do {
return try await self.withFfi {
guard try await self.storage.loadKeyRotation() == nil else {
throw WalletContext.WalletError.operationInProgress
}
if let candidate = try await self.storage.loadReplacementCandidate() {
guard let descriptor = candidate.descriptor else { throw WalletContext.WalletError.storage(.corrupted) }
let existing = try await self.lifecycle.revealRecoveryPhrase(descriptor: descriptor)
guard normalizedEngineMnemonic(existing.phrase.split(whereSeparator: { $0.isWhitespace }).map(String.init)) == normalizedEngineMnemonic(words) else {
throw WalletContext.WalletError.operationInProgress
}
return WalletEngineStagedWallet(
recordId: candidate.recordId, address: candidate.address, publicKey: candidate.publicKey,
signingPublicKey: try walletMnemonicSigningPublicKey(words: words), isPersisted: true
)
}
guard self.transientReplacementDescriptor == nil else {
throw WalletContext.WalletError.operationInProgress
}
let words = normalizedEngineMnemonic(words)
guard detectMnemonicSchemes(words: words).contains(.rotation) else {
throw WalletContext.WalletError.invalidMnemonic
}
await self.platformHost.beginTransientProtectedSecretCapture()
let imported: WalletDescriptor
do {
imported = try await self.lifecycle.importWallet(request: ImportWalletRequest(
recordId: recordId,
network: .mainnet,
recoveryWords: words
))
} catch {
await self.platformHost.cancelTransientProtectedSecretCapture()
await self.platformHost.removeAllTransientProtectedSecrets()
throw error
}
await self.platformHost.cancelTransientProtectedSecretCapture()
guard await self.platformHost.containsTransientProtectedSecret(secretRef: imported.secretRef) else {
throw WalletContext.WalletError.storage(.corrupted)
}
self.transientReplacementDescriptor = imported
return WalletEngineStagedWallet(
recordId: imported.recordId,
address: imported.address,
publicKey: imported.publicKey,
signingPublicKey: try walletMnemonicSigningPublicKey(words: words),
isPersisted: false
)
}
} catch {
if self.transientReplacementDescriptor?.recordId == recordId {
await self.discardTransientReplacementUnlocked(recordId: recordId)
}
throw error
}
}
func stageVerifiedReplacement(words: [String]) async throws -> WalletEngineStagedWallet {
let staged = try await self.stageTransientReplacement(words: words)
do {
try await self.verifyReplacement(recordId: staged.recordId)
return staged
} catch {
let discardPersisted = error as? WalletContext.WalletError == .recoveryPhraseOutdated
await Task {
do { try await self.discardReplacement(recordId: staged.recordId, discardPersisted: discardPersisted) }
catch { self.logger.error("wallet_replacement_cleanup_failed", error) }
}.value
throw error
}
}
func retainReplacementForRetry(recordId: String) async throws {
try await self.withFfi {
guard let candidate = try await self.storage.loadReplacementCandidate(), candidate.recordId == recordId,
let descriptor = candidate.descriptor else { return }
let secret = try await self.storage.readProtectedSecret(ProtectedSecretRead(
secretRef: descriptor.secretRef, reason: .revealRecoveryPhrase, prompt: "Authenticate to import wallet"
))
try await self.platformHost.retainTransientProtectedSecret(secretRef: descriptor.secretRef, bytes: secret)
self.transientReplacementDescriptor = descriptor
try await self.storage.discardReplacementCandidate(recordId: recordId)
}
}
func signOwnershipProof(
replacementRecordId: String? = nil,
expectedAnchorPublicKey: Data,
expectedSigningPublicKey: Data,
domain: String,
timestamp: UInt64,
payload: String
) async throws -> Data {
try await self.withFfi {
let descriptor: WalletDescriptor?
if let replacementRecordId {
guard try await self.storage.loadKeyRotation() == nil else {
throw WalletContext.WalletError.operationInProgress
}
if let transient = self.transientReplacementDescriptor, transient.recordId == replacementRecordId {
descriptor = transient
} else {
descriptor = try await self.storage.loadReplacementCandidate()?.descriptor
}
guard descriptor?.recordId == replacementRecordId else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
} else {
try await self.ensureKeyRotationAllowsSigning()
descriptor = self.descriptor
}
guard expectedAnchorPublicKey.count == 32, expectedSigningPublicKey.count == 32, let descriptor,
descriptor.publicKey == expectedAnchorPublicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let proof = try await self.lifecycle.signTonConnectProof(request: TonConnectProofSignRequest(
descriptor: descriptor,
domain: domain,
timestamp: timestamp,
payload: payload
))
guard proof.publicKey == expectedSigningPublicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
guard proof.signature.count == 64 else {
throw WalletContext.WalletError.proofInvalid
}
if replacementRecordId == nil {
guard let serverIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(serverIdentity.address, descriptor.address),
serverIdentity.publicKey == expectedSigningPublicKey,
self.descriptor == descriptor else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
}
return proof.signature
}
}
func persistReplacementCandidate(recordId: String) async throws {
try await self.withFfi {
if let candidate = try await self.storage.loadReplacementCandidate(), candidate.recordId == recordId { return }
_ = try await self.materializeTransientReplacementUnlocked(recordId: recordId)
}
}
func commitReplacement(
recordId: String,
serverAddress: String,
serverPublicKey: Data,
archivePreviousWallet: Bool = false,
serverStateRevision: UInt64? = nil
) async throws -> WalletEngineActivation {
let revision = serverStateRevision ?? self.serverStateRevision
return try await self.withFfi {
try self.adoptServerWalletIdentity(address: serverAddress, publicKey: serverPublicKey, revision: revision)
if self.transientReplacementDescriptor?.recordId == recordId {
_ = try await self.materializeTransientReplacementUnlocked(recordId: recordId)
}
guard let candidate = try await self.storage.loadReplacementCandidate(),
candidate.recordId == recordId,
walletEngineAddressesEqual(candidate.address, serverAddress),
candidate.descriptor != nil else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let signingPublicKey = try await self.signingPublicKey(for: candidate)
guard self.serverStateRevision == revision else { throw CancellationError() }
guard signingPublicKey == serverPublicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
do {
try await self.verifyImportKey(address: candidate.address, anchorPublicKey: candidate.publicKey, signingPublicKey: signingPublicKey)
} catch {
guard self.serverStateRevision == revision else { throw CancellationError() }
throw error
}
guard self.serverStateRevision == revision else { throw CancellationError() }
try await self.promoteReplacementCandidate(candidate.withSigningPublicKey(signingPublicKey), archivePreviousWallet: archivePreviousWallet)
let activation = try await self.activateUnlocked(
serverAddress: serverAddress,
serverPublicKey: serverPublicKey,
archivePreviousWallet: false,
serverStateRevision: revision
)
guard activation.canSign else { throw WalletContext.WalletError.storage(.identityMismatch) }
return activation
}
}
func reconcileReplacementCandidate(
serverAddress: String,
serverPublicKey: Data,
discardMismatch: Bool,
archivePreviousWallet: Bool = false,
serverStateRevision: UInt64? = nil
) async throws -> Bool {
let revision = serverStateRevision ?? self.serverStateRevision
return try await self.withFfi {
guard self.serverStateRevision <= revision else { throw CancellationError() }
guard let candidate = try await self.storage.loadReplacementCandidate() else {
return false
}
guard try await self.storage.loadKeyRotation() == nil else { return false }
guard let descriptor = candidate.descriptor else {
throw WalletContext.WalletError.storage(.corrupted)
}
// A crash during cleanup may leave only the marker. A Keychain
// access failure throws; it must not be treated as a missing secret.
let hasSecret = try await self.storage.containsProtectedSecret(descriptor.secretRef)
guard self.serverStateRevision <= revision else { throw CancellationError() }
if !hasSecret {
try await self.storage.discardReplacementCandidate(recordId: candidate.recordId)
return false
}
let signingPublicKey: Data
if let known = candidate.signingPublicKey {
signingPublicKey = known
} else {
signingPublicKey = try await self.signingPublicKey(for: candidate)
}
let disposition = try await walletImportCandidateDisposition(
sameAddress: walletEngineAddressesEqual(candidate.address, serverAddress),
sameSigningKey: signingPublicKey == serverPublicKey,
hasPendingKeyRotation: false,
discardMismatch: discardMismatch,
verify: {
try await self.verifyImportKey(address: candidate.address, anchorPublicKey: candidate.publicKey, signingPublicKey: signingPublicKey)
},
validateRevision: {
guard self.serverStateRevision <= revision else { throw CancellationError() }
}
)
switch disposition {
case .promote:
try await self.promoteReplacementCandidate(candidate.withSigningPublicKey(signingPublicKey), archivePreviousWallet: archivePreviousWallet)
return true
case .discard:
try await self.storage.discardReplacementCandidate(recordId: candidate.recordId)
case .retain:
break
}
return false
}
}
func discardReplacement(recordId: String, discardPersisted: Bool = true) async throws {
try await self.withFfi {
if self.transientReplacementDescriptor?.recordId == recordId {
await self.discardTransientReplacementUnlocked(recordId: recordId)
}
guard discardPersisted else { return }
guard let candidate = try await self.storage.loadReplacementCandidate(),
candidate.recordId == recordId else {
return
}
try await self.storage.discardReplacementCandidate(recordId: candidate.recordId)
}
}
func discardReplacementAfterAuthoritativeEmptyState(serverStateRevision: UInt64? = nil) async throws {
let revision = serverStateRevision ?? self.serverStateRevision
try await self.withFfi {
guard try await self.storage.loadKeyRotation() == nil else { return }
guard self.serverStateRevision <= revision else { throw CancellationError() }
await self.discardTransientReplacementUnlocked()
guard let candidate = try await self.storage.loadReplacementCandidate() else {
return
}
try await self.storage.discardReplacementCandidate(recordId: candidate.recordId)
}
}
private func activateUnlocked(
serverAddress: String,
serverPublicKey: Data,
archivePreviousWallet: Bool,
serverStateRevision: UInt64
) async throws -> WalletEngineActivation {
guard serverPublicKey.count == 32 else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
try self.adoptServerWalletIdentity(address: serverAddress, publicKey: serverPublicKey, revision: serverStateRevision)
try await self.shutdownClient()
_ = try await self.reconcileKeyRotationUnlocked(
serverAddress: serverAddress, serverPublicKey: serverPublicKey, backupEnabled: true
)
let stored = try await self.storage.loadDescriptor()
var selectedRecord: WalletEngineDescriptorRecord?
var canSign = false
if let stored,
stored.schemaVersion == 2,
stored.network == "mainnet",
walletEngineAddressesEqual(stored.address, serverAddress),
stored.signingPublicKey == serverPublicKey {
selectedRecord = stored
if let secretRef = stored.secretRef,
try await self.storage.containsProtectedSecret(ProtectedSecretRef(value: secretRef)) {
let verifiedKey = try? await self.signingPublicKey(for: stored)
if let verifiedKey {
guard verifiedKey == serverPublicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
selectedRecord = stored.withSigningPublicKey(verifiedKey)
}
canSign = true
}
}
let record = selectedRecord ?? WalletEngineDescriptorRecord(
recordId: UUID().uuidString.lowercased(),
address: serverAddress,
publicKey: serverPublicKey,
secretRef: nil
)
guard let currentIdentity = self.serverWalletIdentity,
self.serverStateRevision == serverStateRevision,
walletEngineAddressesEqual(currentIdentity.address, serverAddress),
currentIdentity.publicKey == serverPublicKey else {
throw CancellationError()
}
let config = WalletClientConfig(
recordId: record.recordId,
address: record.address,
publicKey: record.publicKey,
localSecretRef: canSign ? record.secretRef.map(ProtectedSecretRef.init(value:)) : nil,
network: .mainnet,
sendValiditySeconds: 300,
resolutionMarginSeconds: 60,
providers: ProviderConfig(
toncenterBaseUrl: "https://toncenter.com",
dnsRootAddress: nil,
requestTimeoutMs: 15_000
)
)
let client = try self.makeClient(config: config)
do {
try await self.storage.saveDescriptor(record)
if archivePreviousWallet, let stored,
!walletEngineAddressesEqual(stored.address, serverAddress),
stored.recordId != record.recordId,
stored.secretRef != record.secretRef {
try await self.archiveLocalWallet(stored)
}
} catch {
try? await client.shutdown()
throw error
}
self.client = client
self.clientConfig = config
self.clientRevision &+= 1
self.descriptor = record.descriptor
try await self.recoverKeyRotationAfterActivation(record: record, client: client)
guard self.serverStateRevision == serverStateRevision else { throw CancellationError() }
self.requiresWalletKeyReconciliation = false
return WalletEngineActivation(
snapshot: try client.snapshot(),
canSign: canSign
)
}
private func promoteReplacementCandidate(_ candidate: WalletEngineDescriptorRecord, archivePreviousWallet: Bool) async throws {
let previous = try await self.storage.loadDescriptor()
try await self.storage.saveDescriptor(candidate)
try await self.storage.removeReplacementCandidate()
if archivePreviousWallet, let previous,
!walletEngineAddressesEqual(previous.address, candidate.address),
previous.recordId != candidate.recordId,
previous.secretRef != candidate.secretRef {
try await self.archiveLocalWallet(previous)
}
}
private func materializeTransientReplacementUnlocked(recordId: String) async throws -> WalletEngineDescriptorRecord {
guard let descriptor = self.transientReplacementDescriptor,
descriptor.recordId == recordId,
let secret = try await self.platformHost.transientProtectedSecret(secretRef: descriptor.secretRef),
!secret.isEmpty else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
guard let phrase = String(data: secret, encoding: .utf8) else {
throw WalletContext.WalletError.invalidMnemonic
}
let signingPublicKey = try walletMnemonicSigningPublicKey(words: phrase.split(whereSeparator: { $0.isWhitespace }).map(String.init))
let record = WalletEngineDescriptorRecord(descriptor: descriptor, signingPublicKey: signingPublicKey)
try await self.storage.installReplacementCandidate(record, secret: secret)
await self.platformHost.removeTransientProtectedSecret(secretRef: descriptor.secretRef)
self.transientReplacementDescriptor = nil
return record
}
private func discardTransientReplacementUnlocked(recordId: String? = nil) async {
guard let descriptor = self.transientReplacementDescriptor,
recordId == nil || descriptor.recordId == recordId else {
return
}
await self.platformHost.removeTransientProtectedSecret(secretRef: descriptor.secretRef)
self.transientReplacementDescriptor = nil
}
private func deleteLocalWallet(_ record: WalletEngineDescriptorRecord) async throws {
if let secretRef = record.secretRef {
try await self.storage.deleteProtectedSecret(ProtectedSecretRef(value: secretRef))
}
}
func setLastKnownBalance(_ value: Int64?) {
self.lastKnownBalance = value
}
private func archiveLocalWallet(_ record: WalletEngineDescriptorRecord) async throws {
let balance = self.lastKnownBalance
self.lastKnownBalance = nil
do {
try await self.storage.archiveWallet(record, balance: balance, archivedAt: currentWalletTimestamp())
} catch {
try await self.deleteLocalWallet(record)
}
}
func archivedWallets() async throws -> [WalletContext.PreviousWallet] {
try await self.storage.availableArchivedWallets().map {
WalletContext.PreviousWallet(
id: $0.descriptor.recordId,
address: $0.descriptor.address,
balance: $0.balance,
lastUsedAt: $0.archivedAt
)
}
}
func refreshArchivedWalletBalances(_ wallets: [WalletContext.PreviousWallet]) async throws -> [WalletContext.PreviousWallet] {
var balances: [String: Int64] = [:]
for address in Set(wallets.map(\.address)) {
try Task.checkCancellation()
do {
balances[address] = try await self.statuslessHost.walletBalance(address: address)
} catch {
try Task.checkCancellation()
self.logger.error("wallet_archived_balance_refresh_failed", error)
}
}
try Task.checkCancellation()
try await self.storage.updateArchivedWalletBalances(balances)
return try await self.archivedWallets()
}
func forgetArchivedWallet(recordId: String) async throws {
try await self.storage.removeArchivedWallet(recordId: recordId)
}
func removeArchivedWallets() async throws {
try await self.storage.removeArchivedWallets()
}
func revealArchivedRecoveryPhrase(recordId: String) async throws -> [String] {
try await self.withFfi {
guard let record = try await self.storage.loadArchivedWallets()
.first(where: { $0.descriptor.recordId == recordId }),
let descriptor = record.descriptor.descriptor else {
throw WalletContext.WalletError.unavailable
}
let phrase = try await self.lifecycle.revealRecoveryPhrase(descriptor: descriptor)
return phrase.phrase.split(separator: " ").map(String.init)
}
}
private func signingPublicKey(for record: WalletEngineDescriptorRecord) async throws -> Data {
guard let descriptor = record.descriptor else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let phrase = try await self.lifecycle.revealRecoveryPhrase(descriptor: descriptor)
var words = normalizedEngineMnemonic(phrase.phrase.split(whereSeparator: { $0.isWhitespace }).map(String.init))
defer { words.removeAll(keepingCapacity: false) }
guard try rotationMnemonicPublicKey(phrase: words.joined(separator: " ")) == record.publicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
return try walletMnemonicSigningPublicKey(words: words)
}
func resolvePending() async throws -> SendSnapshot {
try await self.withFfi(priority: .background) {
try await self.requireClient().resolvePending()
}
}
func snapshot() async throws -> WalletSnapshot {
try await self.withFfi { try self.requireClient().snapshot() }
}
func waitForChange(afterRevision: UInt64) async throws -> WalletSnapshot {
try await self.requireClient().waitForChange(afterRevision: afterRevision)
}
func currentClientRevision() -> UInt64 {
self.clientRevision
}
func resolveDns(_ name: String) async throws -> String? {
try await self.withFfi(priority: .userInitiated) {
try await self.requireClient().resolveDns(name: name)
}
}
func previewSend(intent: SendIntent) async throws -> SendPreview {
try await self.withFfi(priority: .userInitiated, cancellation: .sendPreview) {
try await self.requireClient().previewSend(request: SendPreviewRequest(intent: intent))
}
}
func createEncryptedComment(recipient: String, comment: String, recipientPublicKey: Data? = nil) async throws -> String {
try await self.withFfi(priority: .userInitiated) {
try await self.ensureKeyRotationAllowsSigning()
return try await self.requireClient().createEncryptedComment(request: CreateEncryptedCommentRequest(
recipient: recipient,
comment: comment,
recipientPublicKey: recipientPublicKey
))
}
}
func resolveEncryptedCommentRecipient(recipient: String, recipientPublicKey: Data?) async throws -> Data {
try await self.withFfi(priority: .userInitiated) {
try await self.requireClient().resolveEncryptedCommentRecipient(request: EncryptedCommentRecipientRequest(
recipient: recipient, recipientPublicKey: recipientPublicKey
))
}
}
func decryptComment(sender: String, body: String) async throws -> String {
try await self.withFfi(priority: .userInitiated) {
try await self.ensureKeyRotationAllowsSigning()
return try await self.requireClient().decryptComment(request: DecryptCommentRequest(
sender: sender,
body: body
))
}
}
func prepareTransfer(operationId: String, intent: SendIntent) async throws -> (recordId: String, data: WalletEngineFFI.PreparedTransfer) {
try await self.withFfi(priority: .userInitiated) {
try await self.ensureApiTransferAllowsSigning()
try await self.ensureKeyRotationAllowsSigning()
guard let config = self.clientConfig else {
throw WalletContext.WalletError.unavailable
}
let data = try await self.requireClient().prepareTransfer(request: PrepareTransferRequest(
operationId: operationId,
intent: intent
))
return (config.recordId, data)
}
}
func send(operationId: String, intent: SendIntent) async throws -> WalletEngineSendExecution {
try await self.withFfi(priority: .userInitiated, cancellation: .send) {
try await self.ensureApiTransferAllowsSigning()
try await self.ensureKeyRotationAllowsSigning()
let request = SendRequest(operationId: operationId, force: false, intent: intent)
return try await self.sendRecoveringStuckClient { client in
try await client.send(request: request)
}
}
}
func previewNft(operationId: String, intent: NftTransferIntent) async throws -> SendPreview {
try await self.withFfi(priority: .userInitiated, cancellation: .sendPreview) {
try await self.requireClient().previewNftTransfer(request: NftTransferPreviewRequest(
operationId: operationId,
intent: intent
))
}
}
func sendNft(operationId: String, intent: NftTransferIntent) async throws -> WalletEngineSendExecution {
try await self.withFfi(priority: .userInitiated, cancellation: .send) {
try await self.ensureApiTransferAllowsSigning()
try await self.ensureKeyRotationAllowsSigning()
let request = NftTransferRequest(
operationId: operationId,
force: false,
intent: intent
)
return try await self.sendRecoveringStuckClient { client in
try await client.sendNftTransfer(request: request)
}
}
}
func revealRecoveryPhrase() async throws -> [String] {
try await self.withFfi {
try await self.ensureCurrentWalletIdentity()
guard let descriptor = self.descriptor else {
throw WalletContext.WalletError.unavailable
}
let phrase = try await self.lifecycle.revealRecoveryPhrase(descriptor: descriptor)
return phrase.phrase.split(separator: " ").map(String.init)
}
}
func prepareKeyRotation(validUntil: UInt64) async throws -> PreparedKeyRotation {
try await self.withFfi {
try await self.ensureApiTransferAllowsSigning()
try await self.ensureKeyRotationAllowsSigning()
return try await self.requireClient().prepareKeyRotation(request: PrepareKeyRotationRequest(
validUntil: validUntil,
messageKind: .external
))
}
}
func previewKeyRotation(
operationId: String,
signedBoc: String,
seqno: UInt32,
validUntil: UInt64
) async throws -> SendPreview {
try await self.withFfi(priority: .userInitiated, cancellation: .sendPreview) {
guard validUntil > UInt64(max(0, currentWalletTimestamp())) else {
throw WalletContext.WalletError.preparedBackupDisableExpired
}
do {
return try await self.requireClient().previewSendBoc(request: SendBocRequest(
operationId: operationId,
force: false,
signedBoc: signedBoc,
seqno: seqno,
validUntil: validUntil
))
} catch {
if walletKeyRotationPreparationIsExpired(error, seqno: seqno) {
throw WalletContext.WalletError.preparedBackupDisableExpired
}
throw error
}
}
}
func keyRotationRecord() async throws -> WalletEngineKeyRotationRecord? {
try await self.storage.loadKeyRotation()
}
@discardableResult
func reconcileKeyRotation(serverAddress: String, serverPublicKey: Data, backupEnabled: Bool, serverStateRevision: UInt64? = nil) async throws -> WalletEngineKeyRotationResolution {
let revision = serverStateRevision ?? self.serverStateRevision
return try await self.withFfi {
try self.adoptServerWalletIdentity(address: serverAddress, publicKey: serverPublicKey, revision: revision)
let result = try await self.reconcileKeyRotationUnlocked(
serverAddress: serverAddress, serverPublicKey: serverPublicKey, backupEnabled: backupEnabled
)
guard self.serverStateRevision == revision else { throw CancellationError() }
return result
}
}
private func reconcileKeyRotationUnlocked(serverAddress: String, serverPublicKey: Data, backupEnabled: Bool) async throws -> WalletEngineKeyRotationResolution {
guard let record = try await self.storage.loadKeyRotation(),
walletEngineAddressesEqual(record.walletAddress, serverAddress),
record.newPublicKey == serverPublicKey,
record.phase == .submissionStarted || record.phase == .chainApplied || record.phase == .backupDisabled else {
return .none
}
_ = try await self.storage.markKeyRotationChainApplied(
operationId: record.operationId, verifiedPublicKey: serverPublicKey
)
if !backupEnabled {
try await self.storage.completeKeyRotation(operationId: record.operationId)
}
return .confirmed(operationId: record.operationId)
}
func signBackupDisableProof(
expectedAddress: String,
expectedPublicKey: Data,
rotationOperationId: String?,
domain: String,
timestamp: UInt64,
payload: String
) async throws -> Data {
try await self.withFfi {
guard let descriptor = self.descriptor,
walletEngineAddressesEqual(descriptor.address, expectedAddress),
expectedPublicKey.count == 32 else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let allowedServerPublicKeys: [Data]
if let rotationOperationId {
guard let rotation = try await self.storage.loadKeyRotation(),
rotation.operationId == rotationOperationId,
rotation.recordId == descriptor.recordId,
walletEngineAddressesEqual(rotation.walletAddress, expectedAddress),
rotation.walletPublicKey == descriptor.publicKey,
rotation.activeSecretRef == descriptor.secretRef.value,
rotation.newPublicKey == expectedPublicKey,
rotation.phase == .chainApplied || rotation.phase == .backupDisabled else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
// markKeyRotationChainApplied has already promoted the new
// mnemonic to descriptor.secretRef, so the engine signs with it.
// The chain may already use the new signing key while the server
// still reports the previous one until disableBackup completes.
allowedServerPublicKeys = [rotation.previousPublicKey, rotation.newPublicKey]
} else {
try await self.ensureKeyRotationAllowsSigning()
allowedServerPublicKeys = [expectedPublicKey]
}
func validateIdentity() throws {
guard let serverIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(serverIdentity.address, expectedAddress),
allowedServerPublicKeys.contains(serverIdentity.publicKey),
self.descriptor == descriptor else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
}
try validateIdentity()
try Task.checkCancellation()
let proof = try await self.lifecycle.signTonConnectProof(request: TonConnectProofSignRequest(
descriptor: descriptor, domain: domain, timestamp: timestamp, payload: payload
))
try validateIdentity()
guard proof.publicKey == expectedPublicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
guard proof.signature.count == 64 else {
throw WalletContext.WalletError.proofInvalid
}
return proof.signature
}
}
func keyRotationRecoveryPhrase(operationId: String) async throws -> [String] {
let secret = try await self.storage.keyRotationCandidateSecret(operationId: operationId)
guard let phrase = String(data: secret, encoding: .utf8) else {
throw WalletContext.WalletError.storage(.corrupted)
}
let words = normalizedEngineMnemonic(phrase.split(whereSeparator: { $0.isWhitespace }).map(String.init))
guard words.count == 24, detectMnemonicSchemes(words: words).contains(.rotation) else {
throw WalletContext.WalletError.invalidMnemonic
}
return words
}
func sendKeyRotation(
operationId: String,
words: [String],
previousPublicKey: Data,
newPublicKey: Data,
signedBoc: String,
seqno: UInt32,
validUntil: UInt64
) async throws -> SendResult {
try await self.withFfi {
try await self.ensureApiTransferAllowsSigning()
try await self.ensureKeyRotationAllowsSigning()
guard let descriptor = try await self.storage.loadDescriptor(),
descriptor.recordId == self.descriptor?.recordId,
descriptor.address == self.descriptor?.address,
descriptor.publicKey == self.descriptor?.publicKey,
descriptor.secretRef == self.descriptor?.secretRef.value else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let normalizedWords = normalizedEngineMnemonic(words)
guard normalizedWords.count == 24,
detectMnemonicSchemes(words: normalizedWords).contains(.rotation),
newPublicKey.count == 32,
try walletMnemonicSigningPublicKey(words: normalizedWords) == newPublicKey,
!signedBoc.isEmpty else {
throw WalletContext.WalletError.invalidMnemonic
}
guard let replacementSecret = normalizedWords.joined(separator: " ").data(using: .utf8) else {
throw WalletContext.WalletError.invalidMnemonic
}
let client = try self.requireClient()
try await self.ensureKeyRotationAllowsSigning()
guard previousPublicKey.count == 32,
let signingIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(signingIdentity.address, descriptor.address),
signingIdentity.publicKey == previousPublicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
guard validUntil > UInt64(max(0, currentWalletTimestamp())) else {
throw WalletContext.WalletError.preparedBackupDisableExpired
}
let record = try await self.storage.installKeyRotationCandidate(
operationId: operationId,
descriptor: descriptor,
previousPublicKey: previousPublicKey,
newPublicKey: newPublicKey,
validUntil: validUntil,
candidateSecret: replacementSecret
)
guard record.phase == .candidateStored else {
throw WalletContext.WalletError.unavailable
}
_ = try await self.storage.markKeyRotationSubmissionStarted(operationId: operationId)
do {
guard let currentIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(currentIdentity.address, signingIdentity.address),
currentIdentity.publicKey == signingIdentity.publicKey else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let result = try await client.sendBoc(request: SendBocRequest(
operationId: operationId,
force: false,
signedBoc: signedBoc,
seqno: seqno,
validUntil: validUntil
))
_ = try await self.reconcileKeyRotation(
operationId: result.operationId,
phase: result.phase,
retryAfterMilliseconds: nil
)
return result
} catch {
let snapshot: WalletSnapshot?
do {
snapshot = try client.snapshot()
} catch {
self.logger.error("wallet_key_rotation_snapshot_failed", error)
snapshot = nil
}
if let snapshot {
do {
_ = try await self.reconcileKeyRotation(send: snapshot.send)
} catch {
self.logger.error("wallet_key_rotation_reconciliation_failed", error)
}
}
throw error
}
}
}
func resolveKeyRotation() async throws -> WalletEngineKeyRotationResolution {
try await self.withFfi {
guard let record = try await self.storage.loadKeyRotation() else {
return .none
}
if record.phase == .candidateStored {
try await self.storage.discardUnsubmittedKeyRotation(operationId: record.operationId)
return .rolledBack(operationId: record.operationId, phase: .cancelled)
}
if record.phase == .previousRestored {
try await self.storage.cleanupRestoredKeyRotation(operationId: record.operationId)
return .rolledBack(operationId: record.operationId, phase: .cancelled)
}
if record.phase == .chainApplied || record.phase == .backupDisabled {
return try await self.resolveAppliedKeyRotation(record)
}
do {
let send = try await self.requireClient().resolvePending()
return try await self.reconcileKeyRotation(send: send)
} catch {
let snapshot: WalletSnapshot?
do {
snapshot = try self.requireClient().snapshot()
} catch {
self.logger.error("wallet_key_rotation_snapshot_failed", error)
snapshot = nil
}
if let snapshot, snapshot.send.operationId == record.operationId {
return try await self.reconcileKeyRotation(send: snapshot.send)
}
throw error
}
}
}
func reconcileKeyRotation(send: SendSnapshot) async throws -> WalletEngineKeyRotationResolution {
try await self.reconcileKeyRotation(
operationId: send.operationId,
phase: send.phase,
retryAfterMilliseconds: send.resolution?.retryAfterHintMs
)
}
private func reconcileKeyRotation(
operationId: String?,
phase: SendPhase,
retryAfterMilliseconds: UInt64?
) async throws -> WalletEngineKeyRotationResolution {
guard let record = try await self.storage.loadKeyRotation() else {
return .none
}
if record.phase == .previousRestored {
try await self.storage.cleanupRestoredKeyRotation(operationId: record.operationId)
return .rolledBack(operationId: record.operationId, phase: .cancelled)
}
if record.phase == .chainApplied || record.phase == .backupDisabled {
return try await self.resolveAppliedKeyRotation(record)
}
guard operationId == record.operationId else {
if phase == .idle {
return try await self.resolveTerminalKeyRotation(record, phase: .cancelled)
}
return .pending(operationId: record.operationId, retryAfterMilliseconds: nil)
}
switch phase {
case .confirmed:
return try await self.resolveAppliedKeyRotation(record)
case .replaced, .sequenceNumberConsumed, .expired, .superseded, .failed, .cancelled:
return try await self.resolveTerminalKeyRotation(record, phase: phase)
case .idle, .validating, .authorizing, .preparing, .persisting, .readyToSubmit,
.submitting, .submissionUnknown, .submitted, .handedOff:
return .pending(
operationId: record.operationId,
retryAfterMilliseconds: retryAfterMilliseconds
)
}
}
private func resolveAppliedKeyRotation(
_ record: WalletEngineKeyRotationRecord
) async throws -> WalletEngineKeyRotationResolution {
switch try await self.keyRotationChainState(record) {
case .replacement:
_ = try await self.storage.markKeyRotationChainApplied(
operationId: record.operationId,
verifiedPublicKey: record.newPublicKey
)
return .confirmed(operationId: record.operationId)
case .previous:
let expired = self.keyRotationValidityHasElapsed(record)
try await self.storage.restorePreviousKeyRotationSecret(
operationId: record.operationId,
verifiedPublicKey: record.previousPublicKey,
removeRecord: expired
)
if expired {
return .rolledBack(operationId: record.operationId, phase: .expired)
}
return .pending(operationId: record.operationId, retryAfterMilliseconds: 1_000)
case .different, nil:
return .pending(operationId: record.operationId, retryAfterMilliseconds: 1_000)
}
}
private func resolveTerminalKeyRotation(
_ record: WalletEngineKeyRotationRecord,
phase: SendPhase
) async throws -> WalletEngineKeyRotationResolution {
switch try await self.keyRotationChainState(record) {
case .replacement:
_ = try await self.storage.markKeyRotationChainApplied(
operationId: record.operationId,
verifiedPublicKey: record.newPublicKey
)
return .confirmed(operationId: record.operationId)
case .previous:
try await self.storage.restorePreviousKeyRotationSecret(
operationId: record.operationId,
verifiedPublicKey: record.previousPublicKey,
removeRecord: true
)
return .rolledBack(operationId: record.operationId, phase: phase)
case .different, nil:
return .pending(operationId: record.operationId, retryAfterMilliseconds: 1_000)
}
}
private func keyRotationChainState(
_ record: WalletEngineKeyRotationRecord
) async throws -> WalletEngineKeyRotationChainState? {
if let serverIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(serverIdentity.address, record.walletAddress) {
if serverIdentity.publicKey == record.newPublicKey {
return .replacement
}
if serverIdentity.publicKey != record.previousPublicKey {
return .different
}
}
do {
let publicKey = try await self.statuslessHost.walletPublicKey(address: record.walletAddress)
guard let latestIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(latestIdentity.address, record.walletAddress) else {
return .different
}
if latestIdentity.publicKey == record.newPublicKey {
return .replacement
}
if latestIdentity.publicKey != record.previousPublicKey {
return .different
}
if publicKey == record.newPublicKey {
return .replacement
}
if publicKey == record.previousPublicKey {
return .previous
}
self.logger.log("event=wallet_key_rotation_public_key_mismatch")
return .different
} catch is CancellationError {
throw CancellationError()
} catch {
self.logger.error("wallet_key_rotation_public_key_check_failed", error)
return nil
}
}
private func keyRotationValidityHasElapsed(_ record: WalletEngineKeyRotationRecord) -> Bool {
let (deadline, overflow) = record.validUntil.addingReportingOverflow(120)
guard !overflow else {
return false
}
return UInt64(max(0, Date().timeIntervalSince1970.rounded(.down))) > deadline
}
func completeKeyRotationAfterBackupDisabled(operationId: String) async throws {
try await self.withFfi {
guard let record = try await self.storage.loadKeyRotation() else {
return
}
guard record.operationId == operationId,
(record.phase == .chainApplied || record.phase == .backupDisabled) else {
throw WalletContext.WalletError.storage(.corrupted)
}
let serverConfirmed = self.serverWalletIdentity.map {
walletEngineAddressesEqual($0.address, record.walletAddress) && $0.publicKey == record.newPublicKey
} ?? false
if !serverConfirmed {
guard try await self.keyRotationChainState(record) == .replacement else {
throw WalletContext.WalletError.operationInProgress
}
}
_ = try await self.storage.markKeyRotationChainApplied(
operationId: record.operationId,
verifiedPublicKey: record.newPublicKey
)
try await self.storage.completeKeyRotation(operationId: operationId)
}
}
func tonConnectIdentity() async throws -> TonConnectWalletIdentity {
try await self.withFfi {
try self.currentTonConnectIdentity()
}
}
private func currentTonConnectIdentity() throws -> TonConnectWalletIdentity {
guard let descriptor = self.descriptor, let serverIdentity = self.serverWalletIdentity,
serverIdentity.publicKey.count == 32,
walletEngineAddressesEqual(descriptor.address, serverIdentity.address) else {
throw TonConnectFailure.keyMismatch
}
let account = try self.lifecycle.tonConnectAccount(descriptor: descriptor)
return TonConnectWalletIdentity(recordId: descriptor.recordId, address: account.address, network: account.network, publicKey: serverIdentity.publicKey)
}
private func validateTonConnectWallet(_ wallet: TonConnectWalletIdentity) throws {
guard try self.currentTonConnectIdentity() == wallet else { throw TonConnectFailure.keyMismatch }
}
func tonConnectAccount(wallet: TonConnectWalletIdentity) async throws -> TonConnectAccountInfo {
try await self.withFfi {
try self.validateTonConnectWallet(wallet)
guard let descriptor = self.descriptor else {
throw WalletContext.WalletError.unavailable
}
let account = try self.lifecycle.tonConnectAccount(descriptor: descriptor)
return TonConnectAccountInfo(
address: account.address,
network: account.network,
walletStateInit: account.walletStateInit,
publicKey: wallet.publicKey
)
}
}
func signTonConnectProof(
wallet: TonConnectWalletIdentity,
manifestUrl: String,
timestamp: UInt64,
payload: String,
beforeSigning: @escaping @Sendable () throws -> Void = {}
) async throws -> TonConnectProofReply {
let domain = try TonConnectWireCodec.proofDomain(manifestUrl: manifestUrl)
return try await self.withFfi(beforeSigning: beforeSigning) {
try self.validateTonConnectWallet(wallet)
try await self.ensureKeyRotationAllowsSigning()
guard let descriptor = self.descriptor else {
throw WalletContext.WalletError.unavailable
}
try beforeSigning()
let proof = try await self.lifecycle.signTonConnectProof(request: TonConnectProofSignRequest(
descriptor: descriptor,
domain: domain,
timestamp: timestamp,
payload: payload
))
try self.validateTonConnectWallet(wallet)
guard proof.publicKey == wallet.publicKey else { throw TonConnectFailure.keyMismatch }
return TonConnectProofReply(timestamp: timestamp, domain: domain, payload: payload, signature: proof.signature)
}
}
func previewTonConnect(_ request: SendRequest, wallet: TonConnectWalletIdentity) async throws -> SendPreview {
try await self.withFfi(priority: .userInitiated, cancellation: .sendPreview) {
try self.validateTonConnectWallet(wallet)
return try await self.requireClient().previewTonConnect(request: request)
}
}
func sendTonConnect(_ request: SendRequest, wallet: TonConnectWalletIdentity, beforeSigning: @escaping @Sendable () throws -> Void = {}) async throws -> SendResult {
try await self.withFfi(priority: .userInitiated, cancellation: .send, beforeSigning: beforeSigning) {
try await self.ensureApiTransferAllowsSigning()
try self.validateTonConnectWallet(wallet)
try await self.ensureKeyRotationAllowsSigning()
let client = try self.requireClient()
try beforeSigning()
return try await client.send(request: request)
}
}
func shutdown() async {
do {
try await self.withFfi {
await self.discardTransientReplacementUnlocked()
await self.platformHost.removeAllTransientProtectedSecrets()
try await self.shutdownClient()
}
} catch {
self.logger.error("wallet_engine_shutdown_failed", error)
}
}
private func recoverKeyRotationAfterActivation(
record descriptor: WalletEngineDescriptorRecord,
client: WalletClient
) async throws {
guard let rotation = try await self.storage.loadKeyRotation() else {
return
}
guard rotation.recordId == descriptor.recordId,
walletEngineAddressesEqual(rotation.walletAddress, descriptor.address),
rotation.walletPublicKey == descriptor.publicKey,
rotation.activeSecretRef == descriptor.secretRef else {
if rotation.recordId == descriptor.recordId || rotation.activeSecretRef == descriptor.secretRef {
throw WalletContext.WalletError.storage(.corrupted)
}
try await self.storage.discardOrphanedKeyRotation(
operationId: rotation.operationId,
activeSecretRef: descriptor.secretRef
)
return
}
if let serverIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(serverIdentity.address, rotation.walletAddress),
serverIdentity.publicKey == rotation.newPublicKey,
rotation.phase == .submissionStarted || rotation.phase == .chainApplied || rotation.phase == .backupDisabled {
_ = try await self.storage.markKeyRotationChainApplied(
operationId: rotation.operationId, verifiedPublicKey: rotation.newPublicKey
)
return
}
switch rotation.phase {
case .candidateStored:
try await self.storage.discardUnsubmittedKeyRotation(operationId: rotation.operationId)
case .submissionStarted:
do {
let send = try await client.resolvePending()
_ = try await self.reconcileKeyRotation(send: send)
} catch {
self.logger.error("wallet_key_rotation_recovery_failed", error)
}
case .chainApplied, .backupDisabled:
_ = try await self.resolveAppliedKeyRotation(rotation)
case .previousRestored:
try await self.storage.cleanupRestoredKeyRotation(operationId: rotation.operationId)
}
}
private func shutdownClient() async throws {
if let client = self.client {
self.client = nil
self.clientConfig = nil
try await client.shutdown()
}
}
private func makeClient(
config: WalletClientConfig,
statuslessHost: WalletEngineStatuslessHost? = nil
) throws -> WalletClient {
try WalletClient.newStatusless(
config: config,
statuslessHost: statuslessHost ?? self.statuslessHost,
platformHost: self.platformHost
)
}
private func sendRecoveringStuckClient(
_ operation: (WalletClient) async throws -> SendResult
) async throws -> WalletEngineSendExecution {
let client = try self.requireClient()
do {
return WalletEngineSendExecution(
result: try await operation(client),
didRecreateClient: false
)
} catch {
guard walletEngineIsSendAlreadyInProgress(error) else {
throw error
}
guard self.client === client, let config = self.clientConfig else {
throw error
}
self.logger.error("wallet_engine_stuck_send_recovery_started", error)
try await client.shutdown()
self.client = nil
let replacementStatuslessHost = WalletEngineStatuslessHost(
engine: self.engine,
logger: self.logger
)
let replacement: WalletClient
do {
replacement = try self.makeClient(
config: config,
statuslessHost: replacementStatuslessHost
)
} catch {
self.clientConfig = nil
throw error
}
self.statuslessHost = replacementStatuslessHost
self.client = replacement
self.clientRevision &+= 1
return WalletEngineSendExecution(
result: try await operation(replacement),
didRecreateClient: true
)
}
}
func ensureApiTransferAllowsSigning() async throws {
guard !self.requiresWalletKeyReconciliation else { throw WalletContext.WalletError.walletKeyMismatch }
guard let descriptor = self.descriptor else { throw WalletContext.WalletError.unavailable }
if let record = try await self.storage.loadTransferSubmissions().first(where: {
$0.recordId == descriptor.recordId || walletEngineAddressesEqual($0.walletAddress, descriptor.address)
}),
record.resolution == .pending {
throw WalletContext.WalletError.operationInProgress
}
}
private func ensureKeyRotationAllowsSigning() async throws {
if try await self.storage.loadKeyRotation() != nil {
throw WalletContext.WalletError.operationInProgress
}
try await self.ensureCurrentWalletIdentity()
}
private func ensureCurrentWalletIdentity() async throws {
guard !self.requiresWalletKeyReconciliation else { throw WalletContext.WalletError.walletKeyMismatch }
guard let descriptor = self.descriptor,
let serverIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(descriptor.address, serverIdentity.address),
let stored = try await self.storage.loadDescriptor(),
stored.recordId == descriptor.recordId,
stored.publicKey == descriptor.publicKey,
stored.signingPublicKey == serverIdentity.publicKey,
stored.secretRef == descriptor.secretRef.value else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
let signingPublicKey = try await self.signingPublicKey(for: stored)
guard let currentIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(currentIdentity.address, descriptor.address),
currentIdentity.publicKey == signingPublicKey,
self.descriptor?.recordId == descriptor.recordId,
self.descriptor?.publicKey == descriptor.publicKey,
self.descriptor?.secretRef == descriptor.secretRef else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
if stored.signingPublicKey != signingPublicKey {
try await self.storage.saveDescriptor(stored.withSigningPublicKey(signingPublicKey))
}
guard let latestIdentity = self.serverWalletIdentity,
walletEngineAddressesEqual(latestIdentity.address, descriptor.address),
latestIdentity.publicKey == signingPublicKey,
self.descriptor?.recordId == descriptor.recordId,
self.descriptor?.publicKey == descriptor.publicKey,
self.descriptor?.secretRef == descriptor.secretRef else {
throw WalletContext.WalletError.storage(.identityMismatch)
}
}
private func requireClient() throws -> WalletClient {
guard let client = self.client else {
throw WalletContext.WalletError.unavailable
}
return client
}
private func withFfi<Value>(
priority: FfiPriority = .userInitiated,
cancellation: FfiCancellation = .none,
beforeSigning: (@Sendable () throws -> Void)? = nil,
_ operation: @escaping () async throws -> Value
) async throws -> Value {
if let session = WalletAuthorizationScope.session {
try await session.waitUntilAvailable()
}
await self.acquireFfi(priority: priority)
defer {
self.activeFfiOperation = nil
self.releaseFfi()
}
if let session = WalletAuthorizationScope.session {
try await session.waitUntilAvailable()
}
try Task.checkCancellation()
let operationId = UUID()
self.activeFfiOperation = (operationId, cancellation)
await self.platformHost.setAuthorization(WalletAuthorizationScope.session)
await self.platformHost.setTonConnectSigningGuard(beforeSigning)
let operationTask = Task { () -> Result<Value, Error> in
do {
return .success(try await operation())
} catch {
return .failure(error)
}
}
let result = await withTaskCancellationHandler(operation: {
await operationTask.value
}, onCancel: { [weak self] in
Task {
await self?.cancelActiveFfiOperation(id: operationId, cancellation: cancellation)
}
})
await self.platformHost.setTonConnectSigningGuard(nil)
await self.platformHost.setAuthorization(nil)
try Task.checkCancellation()
return try result.get()
}
private func acquireFfi(priority: FfiPriority = .userInitiated) async {
if !self.ffiBusy {
self.ffiBusy = true
return
}
await withCheckedContinuation { continuation in
switch priority {
case .userInitiated:
self.userInitiatedFfiWaiters.append(continuation)
case .background:
self.backgroundFfiWaiters.append(continuation)
}
}
}
private func releaseFfi() {
if !self.userInitiatedFfiWaiters.isEmpty {
self.userInitiatedFfiWaiters.removeFirst().resume()
} else if !self.backgroundFfiWaiters.isEmpty {
self.backgroundFfiWaiters.removeFirst().resume()
} else {
self.ffiBusy = false
}
}
private func cancelActiveFfiOperation(id: UUID, cancellation: FfiCancellation) async {
guard cancellation != .none,
self.activeFfiOperation?.id == id,
self.activeFfiOperation?.cancellation == cancellation,
let client = self.client else {
return
}
do {
switch cancellation {
case .none:
return
case .sendPreview:
try await client.cancelSendPreview()
case .send:
try await client.cancelSend()
}
} catch {
self.logger.error("wallet_engine_operation_cancellation_failed", error)
}
}
}
@available(macOS 10.15, *)
func walletEngineIsSendAlreadyInProgress(_ error: Error) -> Bool {
guard let error = error as? WalletClientError else {
return false
}
if case .SendAlreadyInProgress = error {
return true
}
return false
}
@available(macOS 10.15, *)
func normalizedEngineMnemonic(_ words: [String]) -> [String] {
words.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
}
@available(macOS 10.15, *)
func walletEngineAddressesEqual(_ lhs: String, _ rhs: String) -> Bool {
guard let left = try? convertTonAddress(value: lhs, format: .raw),
let right = try? convertTonAddress(value: rhs, format: .raw) else {
return lhs == rhs
}
return left == right
}
@available(macOS 10.15, *)
extension WalletEngineRuntime {
func validateTonConnectAccess(wallet: TonConnectWalletIdentity) async throws {
try await self.withFfi {
try await self.ensureKeyRotationAllowsSigning()
try self.validateTonConnectWallet(wallet)
}
}
func tonConnectSessionPublicKey(wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession) async throws -> String {
try await self.withTonConnectSession(wallet: wallet, session: session, allowPendingRegistration: true) {
$0.publicKeyHex()
}
}
func openTonConnectChallenge(_ data: Data, wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession) async throws -> Data {
try await self.withTonConnectSession(wallet: wallet, session: session, allowPendingRegistration: true) {
try $0.openChallenge(challenge: data)
}
}
func decodeTonConnectRequest(_ data: Data, wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession, now: UInt64, operationId: String) async throws -> TonConnectWireRequest {
guard !data.isEmpty, data.count <= TonConnectWireCodec.maximumPacketBytes, !operationId.isEmpty else {
throw TonConnectWireFailure(code: .badRequest)
}
return try await self.withTonConnectSession(wallet: wallet, session: session, allowPendingRegistration: false) { derived in
switch try derived.decryptRequest(body: data, now: now).request {
case let .sendTransaction(id, _, request):
return .sendTransaction(id: try TonConnectRequestId(id), request: SendRequest(
operationId: operationId, force: false, intent: request.intent
))
case let .signData(rawId, _, request):
let id = try TonConnectRequestId(rawId)
do {
return .signData(id: id, payload: try TonConnectSignDataPayload(request))
} catch let failure as TonConnectWireFailure {
throw TonConnectWireFailure(requestId: id, code: failure.code, message: failure.message)
}
case let .disconnect(id, _):
return .disconnect(id: try TonConnectRequestId(id))
case let .unsupported(id, _, code, message):
throw TonConnectWireFailure(requestId: try TonConnectRequestId(id), code: TonConnectWireErrorCode(code), message: message)
case let .signMessage(id, _, _):
throw TonConnectWireFailure(requestId: try TonConnectRequestId(id), code: .methodNotSupported)
}
}
}
func encryptTonConnectEvent(eventId: Int64, account: TonConnectAccountInfo, proof: TonConnectProofReply?, device: TonConnectDevice,
wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession) async throws -> Data {
let eventId = try Self.tonConnectEventId(eventId)
return try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptConnectEvent(eventId: eventId, account: account, proof: proof, device: device)
}
}
func encryptTonConnectConnectError(eventId: Int64, code: TonConnectConnectErrorCode, message: String,
wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession) async throws -> Data {
let eventId = try Self.tonConnectEventId(eventId)
return try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptConnectError(eventId: eventId, code: code, message: message)
}
}
func encryptTonConnectSendSuccess(id: TonConnectRequestId, signedBoc: String, wallet: TonConnectWalletIdentity,
session: TelegramCore.WalletTonConnectSession) async throws -> Data {
try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptSendSuccess(requestId: id.rawValue, signedBoc: signedBoc)
}
}
func encryptTonConnectSignDataSuccess(id: TonConnectRequestId, signedData: TonConnectSignedData, wallet: TonConnectWalletIdentity,
session: TelegramCore.WalletTonConnectSession) async throws -> Data {
try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptSignDataSuccess(requestId: id.rawValue, signedData: signedData)
}
}
func encryptTonConnectError(id: TonConnectRequestId, code: TonConnectWireErrorCode, message: String? = nil,
wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession) async throws -> Data {
try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptError(requestId: id.rawValue, code: code.engineCode, message: message ?? code.message)
}
}
func encryptTonConnectDisconnectSuccess(id: TonConnectRequestId, wallet: TonConnectWalletIdentity,
session: TelegramCore.WalletTonConnectSession) async throws -> Data {
try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptDisconnectSuccess(requestId: id.rawValue)
}
}
func encryptTonConnectDisconnectEvent(eventId: Int64, wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession) async throws -> Data {
let eventId = try Self.tonConnectEventId(eventId)
return try await self.encryptTonConnectPacket(wallet: wallet, session: session) {
try $0.encryptDisconnectEvent(eventId: eventId)
}
}
private func encryptTonConnectPacket(wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession,
_ encrypt: @escaping (TonConnectDerivedSession) throws -> Data) async throws -> Data {
try await self.withTonConnectSession(wallet: wallet, session: session, allowPendingRegistration: true) { derived in
let body = try encrypt(derived)
guard body.count <= TonConnectWireCodec.maximumPacketBytes else { throw TonConnectWireFailure(code: .badRequest) }
return body
}
}
private static func tonConnectEventId(_ value: Int64) throws -> UInt64 {
guard let value = UInt64(exactly: value) else { throw TonConnectWireFailure(code: .badRequest) }
return value
}
func signTonConnectData(_ payload: TonConnectSignDataPayload, domain: String, timestamp: UInt64, wallet: TonConnectWalletIdentity, beforeSigning: @escaping @Sendable () throws -> Void = {}) async throws -> TonConnectSignedData {
try await self.withFfi(beforeSigning: beforeSigning) {
try await self.ensureKeyRotationAllowsSigning()
try self.validateTonConnectWallet(wallet)
guard let descriptor = self.descriptor else { throw WalletContext.WalletError.unavailable }
try beforeSigning()
let signed = try await self.lifecycle.signTonConnectData(request: TonConnectSignDataSignRequest(
descriptor: descriptor, request: payload.engineRequest, domain: domain, timestamp: timestamp
))
try self.validateTonConnectWallet(wallet)
guard signed.publicKey == wallet.publicKey else { throw TonConnectFailure.keyMismatch }
return signed
}
}
private func withTonConnectSession<Value>(wallet: TonConnectWalletIdentity, session: TelegramCore.WalletTonConnectSession, allowPendingRegistration: Bool, _ operation: @escaping (TonConnectDerivedSession) throws -> Value) async throws -> Value {
_ = try Self.tonConnectPublicKey(session.dappClientId)
let registeredPublicKey = try session.clientId.map(Self.tonConnectPublicKey)
guard !session.nonce.isEmpty, session.nonce.count <= TonConnectWireCodec.maximumPacketBytes,
registeredPublicKey != nil || (allowPendingRegistration && session.isPending && !session.isClosing && !session.isClosed) else {
throw TonConnectFailure.keyMismatch
}
return try await self.withFfi {
try await self.ensureKeyRotationAllowsSigning()
try self.validateTonConnectWallet(wallet)
_ = try self.requireClient()
guard let descriptor = self.descriptor else { throw WalletContext.WalletError.unavailable }
let derived = try await self.lifecycle.deriveTonConnectSession(request: TonConnectDerivedSessionRequest(
descriptor: descriptor, dappClientId: session.dappClientId.lowercased(), nonce: session.nonce
))
try self.validateTonConnectWallet(wallet)
guard self.descriptor?.secretRef == descriptor.secretRef,
derived.signingPublicKey() == wallet.publicKey else { throw TonConnectFailure.keyMismatch }
if let registeredPublicKey, try Self.tonConnectPublicKey(derived.publicKeyHex()) != registeredPublicKey {
throw TonConnectFailure.keyMismatch
}
try Task.checkCancellation()
return try operation(derived)
}
}
private static func tonConnectPublicKey(_ value: String) throws -> Data {
guard value.utf8.count == 64 else { throw TonConnectFailure.unavailable }
let bytes = Array(value.utf8)
func nibble(_ byte: UInt8) throws -> UInt8 {
switch byte {
case 48...57: return byte - 48
case 65...70: return byte - 55
case 97...102: return byte - 87
default: throw TonConnectFailure.unavailable
}
}
var result = Data(capacity: 32)
for index in stride(from: 0, to: bytes.count, by: 2) {
result.append(try (nibble(bytes[index]) << 4) | nibble(bytes[index + 1]))
}
return result
}
}