From 2ca2ef1e77de801f44ee0cb395362d45c8597154 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 14:00:07 +0300 Subject: [PATCH 01/35] feat!: add Tron support and replace walletAddress with activeWallet Port Tron support from the TypeScript SDK (0xPolygon/oms-wallet-typescript-sdk#104). - Regenerate the WaaS client from waas 8a2c7f6 (schema v1-26.9.22-f21ed9d2): adds PrepareTronTransaction, PrepareTronContractCall, the `tron` wallet type and network family, and `networkFamily` on IsValidTypedDataSignature. - Add WalletType.tron, TronNetwork/TronNetworks (tron:mainnet, tron:nile), sendTronTransaction (native mode, sun values, `data` forwarded as given), callTronContract, signTronMessage, signTronTypedData, isValidTronMessageSignature, isValidTronTypedDataSignature, and Tron private key import. - Add IndexerClient.getTronBalances through the OMS Tron indexer gateway and use it for Tron fee-option balances so FeeOptionSelector.firstAvailable works for unsponsored Tron fees. - Guard wallet-family methods by the stored wallet type instead of address shape. - Reject contract method names that are not bare function names before any request. - Validate Ethereum wallet addresses in wallet responses (shape only). BREAKING CHANGE: WalletClient.walletAddress is replaced by activeWallet (Wallet?). OMSWalletSessionState is renamed to OMSWalletSession; WalletClient.session is now optional with non-optional expiresAt and auth, and is non-nil exactly when activeWallet is. walletAddress is removed from WalletSelectionResult and CompleteAuthResult.walletSelected. OMSWalletSessionExpiredEvent gains `wallet`. Stored sessions now persist the full wallet (record version 2); version 1 records are discarded on load, so users sign in once after upgrading. --- API.md | 236 ++++++- .../oms-wallet-demo/ContentView.swift | 498 ++++++++++++++- .../trails-actions/TrailsDemoViewModel.swift | 40 +- MIGRATION.md | 90 +++ README.md | 131 +++- Sources/OMSWallet/Clients/IndexerClient.swift | 47 +- Sources/OMSWallet/Clients/WalletAuth.swift | 42 +- Sources/OMSWallet/Clients/WalletClient.swift | 39 +- Sources/OMSWallet/Clients/WalletImports.swift | 7 +- .../OMSWallet/Clients/WalletOperations.swift | 263 +++++++- .../OMSWallet/Clients/WalletSessions.swift | 118 ++-- .../Generated/WaasGeneratedAliases.swift | 2 + Sources/OMSWallet/Generated/waas.gen.swift | 151 ++++- .../Models/Auth/CompleteAuthResult.swift | 14 +- .../Models/Auth/WalletSelectionResult.swift | 4 +- .../Models/Indexer/TronIndexerModels.swift | 163 +++++ Sources/OMSWallet/Models/OMSWalletError.swift | 7 + .../Models/Sessions/OMSWalletSession.swift | 55 ++ .../Sessions/OMSWalletSessionState.swift | 58 -- .../Models/Sessions/SessionMetadata.swift | 2 +- .../Models/Sessions/StorableCredentials.swift | 73 ++- .../OMSWallet/Models/WaasModelMapping.swift | 12 +- Sources/OMSWallet/Models/WalletModels.swift | 5 + Sources/OMSWallet/Network.swift | 12 + Sources/OMSWallet/OMSWalletEnvironment.swift | 9 +- Sources/OMSWallet/PublishableKey.swift | 5 +- .../Signer/WalletCredentialSession.swift | 15 +- Sources/OMSWallet/WalletImport.swift | 47 +- .../ActiveWalletSessionTests.swift | 232 +++++++ Tests/OMSWalletTests/IndexerTests.swift | 183 +++++- Tests/OMSWalletTests/MockWalletTests.swift | 250 +++++--- .../PublicErrorContractsTests.swift | 8 +- Tests/OMSWalletTests/RequestsTests.swift | 68 +- Tests/OMSWalletTests/TronWalletTests.swift | 584 ++++++++++++++++++ .../OMSWalletTests/WaasGeneratedAliases.swift | 2 + docs/error-contracts.md | 13 +- scripts/api-presentation.json | 55 +- scripts/public-api-baseline.txt | 146 ++++- 38 files changed, 3239 insertions(+), 447 deletions(-) create mode 100644 Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift create mode 100644 Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift delete mode 100644 Sources/OMSWallet/Models/Sessions/OMSWalletSessionState.swift create mode 100644 Tests/OMSWalletTests/ActiveWalletSessionTests.swift create mode 100644 Tests/OMSWalletTests/TronWalletTests.swift diff --git a/API.md b/API.md index 06c1c00..d62ee8b 100644 --- a/API.md +++ b/API.md @@ -22,12 +22,15 @@ public final class WalletClient: @unchecked Sendable ## Authentication and sessions -### `WalletClient.walletAddress` +### `WalletClient.activeWallet` ```swift -public internal(set) var walletAddress: String? { get set } +public internal(set) var activeWallet: Wallet? { get set } ``` +The active wallet, or `nil` until auth completes or a session is restored, and after +sign-out or session expiry. Same shape as `listWallets()` entries. + ### `WalletClient.walletId` ```swift @@ -37,10 +40,11 @@ public internal(set) var walletId: String { get set } ### `WalletClient.session` ```swift -public var session: OMSWalletSessionState { get } +public var session: OMSWalletSession? { get } ``` -Snapshot of the current durable wallet-session state. +Expiry and auth metadata for the active wallet session. Non-`nil` exactly when +`activeWallet` is. ### `WalletClient.addSessionExpiredObserver(_:)` @@ -129,7 +133,7 @@ its address and signer metadata to the keychain. @discardableResult public func importWallet(privateKey: WalletImportPrivateKey, reference: String? = nil) async throws -> WalletSelectionResult ``` -Imports and activates an Ethereum or Solana private key. +Imports and activates an Ethereum, Solana, or Tron private key. ### `WalletClient.getWalletImportRecipientKey(cipherSuite:)` @@ -246,14 +250,12 @@ public enum WalletSelectionBehavior: String, Codable, Equatable, Sendable { ```swift public enum CompleteAuthResult: Sendable { case walletSelected( - walletAddress: String, wallet: Wallet, wallets: [Wallet], credential: WalletCredential ) case walletSelection(PendingWalletSelection) public var credential: WalletCredential { get } - public var walletAddress: String? { get } public var wallet: Wallet? { get } } ``` @@ -274,9 +276,8 @@ public final class PendingWalletSelection: @unchecked Sendable { ```swift public struct WalletSelectionResult: Sendable { - public let walletAddress: String public let wallet: Wallet - public init(walletAddress: String, wallet: Wallet) + public init(wallet: Wallet) } ``` @@ -300,6 +301,7 @@ public struct Wallet: Codable, Equatable, Sendable { public enum WalletType: Codable, Equatable, Hashable, Sendable { case ethereum case solana + case tron case unknown(String) public var wireValue: String { get } public init(wireValue: String) @@ -342,6 +344,11 @@ public enum WalletImportPrivateKey: Sendable { case ethereumBytes(Data) case solana(String) case solanaBytes(Data) + /// A secp256k1 private key as 64 hexadecimal characters, optionally `0x`-prefixed. Tron uses + /// the same private key format as Ethereum. + case tron(String) + /// A 32-byte secp256k1 private key. + case tronBytes(Data) } ``` @@ -549,20 +556,21 @@ public enum OIDCRedirectAuthResult: Sendable { Result of handling an incoming OIDC authorization-code redirect callback. -### `OMSWalletSessionState` +### `OMSWalletSession` ```swift -public struct OMSWalletSessionState: Equatable, Sendable { - /// Address of the selected wallet in a completed session, or `nil` when the SDK is signed out. - public let walletAddress: String? - /// Expiration time for the current completed wallet session, or `nil` when unavailable. - public let expiresAt: Date? - /// Auth metadata for the current completed wallet session. - public let auth: OMSWalletSessionAuth? - public init(walletAddress: String?, expiresAt: Date? = nil, auth: OMSWalletSessionAuth? = nil) +public struct OMSWalletSession: Equatable, Sendable { + /// Expiration time for the active wallet session. + public let expiresAt: Date + /// Auth metadata for the active wallet session. + public let auth: OMSWalletSessionAuth + public init(expiresAt: Date, auth: OMSWalletSessionAuth) } ``` +Expiry and auth metadata for the active wallet session. `WalletClient.session` is non-`nil` +exactly when `WalletClient.activeWallet` is. + ### `OMSWalletSessionAuth` ```swift @@ -615,9 +623,12 @@ public enum OMSWalletOidcSessionAuthFlow: String, Codable, Equatable, Sendable { ```swift public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { - public let session: OMSWalletSessionState + /// The wallet that was active when the session expired, or `nil` when the credential expired + /// while a manual wallet selection was still pending. + public let wallet: Wallet? + public let session: OMSWalletSession public let expiredAt: Date - public init(session: OMSWalletSessionState, expiredAt: Date) + public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: Date) } ``` @@ -663,6 +674,18 @@ public func signSolanaMessage(message: String) async throws -> String public func signTypedData(network: Network, typedData: JSONValue) async throws -> String ``` +### `WalletClient.signTronMessage(message:)` + +```swift +public func signTronMessage(message: String) async throws -> String +``` + +### `WalletClient.signTronTypedData(typedData:)` + +```swift +public func signTronTypedData(typedData: JSONValue) async throws -> String +``` + ### `WalletClient.isValidMessageSignature(network:walletAddress:message:signature:)` ```swift @@ -681,6 +704,18 @@ public func isValidSolanaMessageSignature(walletAddress: String, message: String public func isValidTypedDataSignature(network: Network, walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool ``` +### `WalletClient.isValidTronMessageSignature(walletAddress:message:signature:)` + +```swift +public func isValidTronMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool +``` + +### `WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:)` + +```swift +public func isValidTronTypedDataSignature(walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool +``` + ### `WalletClient.sendTransaction(network:to:value:selectFeeOption:mode:waitForStatus:statusPolling:)` ```swift @@ -699,12 +734,24 @@ public func sendTransaction(network: Network, request: SendTransactionRequest, s public func sendSolanaTransfer(network: SolanaNetwork, asset: String, to: String, amount: String, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse ``` +### `WalletClient.sendTronTransaction(network:to:value:data:selectFeeOption:waitForStatus:statusPolling:)` + +```swift +public func sendTronTransaction(network: TronNetwork, to: String, value: String = "0", data: String? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +``` + ### `WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)` ```swift public func callContract(network: Network, contract: String, method: String, args: [AbiArg]?, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse ``` +### `WalletClient.callTronContract(network:contract:method:args:selectFeeOption:waitForStatus:statusPolling:)` + +```swift +public func callTronContract(network: TronNetwork, contract: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +``` + ### `WalletClient.getTransactionStatus(txnId:)` ```swift @@ -1029,6 +1076,128 @@ public enum SolanaTokenProgram: String, Codable, Sendable { } ``` +### `IndexerClient.getTronBalances(_:)` + +```swift +public func getTronBalances(_ params: GetTronBalancesParams) async throws -> TronBalancesResult +``` + +### `GetTronBalancesParams` + +```swift +public struct GetTronBalancesParams: Sendable { + /// Base58Check (`T…`) wallet address. + public let walletAddress: String + public let networks: [TronNetwork] + public let includeMetadata: Bool + public let omitNativeBalances: Bool? + /// Only return these TRC-20 contracts (`T…`). + public let contractAddresses: [String] + /// Exclude these TRC-20 contracts (`T…`). + public let excludedContractAddresses: [String] + public init(walletAddress: String, networks: [TronNetwork] = [.mainnet, .nile], includeMetadata: Bool = true, omitNativeBalances: Bool? = nil, contractAddresses: [String] = [], excludedContractAddresses: [String] = []) +} +``` + +### `TronBalancesResult` + +```swift +public struct TronBalancesResult: Sendable { + public let status: Int + public let balances: [TronBalance] + public let errors: [TronNetworkError] +} +``` + +### `TronBalance` + +```swift +public enum TronBalance: Decodable, Sendable { + case native(TronNativeBalance) + case fungibleToken(TronFungibleTokenBalance) + public init(from decoder: Decoder) throws +} +``` + +### `TronNativeBalance` + +```swift +public struct TronNativeBalance: Codable, Sendable { + public let network: TronNetwork + /// Base58Check (`T…`) account address. + public let accountAddress: String + public let name: String + public let symbol: String + public let decimals: Int + /// Raw balance in sun (1 TRX = 1,000,000 sun). + public let balance: String + public let formattedBalance: String + public let imageUrl: String? + public let metadataUri: String? + public let verificationStatus: TronVerificationStatus + public let verificationSource: String + public let priceUSD: String? + public let balanceUSD: String? + public init(from decoder: Decoder) throws +} +``` + +### `TronFungibleTokenBalance` + +```swift +public struct TronFungibleTokenBalance: Codable, Sendable { + public let network: TronNetwork + /// Base58Check (`T…`) account address. + public let accountAddress: String + public let tokenStandard: TronTokenStandard + /// Base58Check (`T…`) TRC-20 contract address. + public let contractAddress: String + public let name: String + public let symbol: String + public let decimals: Int + /// Raw balance in the token's base units. + public let balance: String + public let formattedBalance: String + public let imageUrl: String? + public let metadataUri: String? + public let verificationStatus: TronVerificationStatus + public let verificationSource: String + public let priceUSD: String? + public let balanceUSD: String? + public init(from decoder: Decoder) throws +} +``` + +### `TronNetworkError` + +```swift +public struct TronNetworkError: Codable, Sendable { + public let network: TronNetwork + public let reason: String + public init(from decoder: any Decoder) throws +} +``` + +### `TronVerificationStatus` + +```swift +public enum TronVerificationStatus: String, Codable, Sendable { + case verified + case unverified + case unknown + public init?(rawValue: String) +} +``` + +### `TronTokenStandard` + +```swift +public enum TronTokenStandard: String, Codable, Sendable { + case trc20 + public init?(rawValue: String) +} +``` + ### `GetBalancesParams` ```swift @@ -1419,6 +1588,26 @@ public enum SolanaNetworks { } ``` +### `TronNetwork` + +```swift +public enum TronNetwork: String, CaseIterable, Codable, Sendable, CustomStringConvertible { + case mainnet = "tron:mainnet" + case nile = "tron:nile" + public var description: String { get } + public init?(rawValue: String) +} +``` + +### `TronNetworks` + +```swift +public enum TronNetworks { + public static let mainnet: TronNetwork + public static let nile: TronNetwork +} +``` + ### `JSONValue` ```swift @@ -1509,14 +1698,21 @@ public enum OMSWalletOperation: String, Sendable { case walletIsValidMessageSignature = "wallet.isValidMessageSignature" case walletIsValidSolanaMessageSignature = "wallet.isValidSolanaMessageSignature" case walletIsValidTypedDataSignature = "wallet.isValidTypedDataSignature" + case walletSignTronMessage = "wallet.signTronMessage" + case walletSignTronTypedData = "wallet.signTronTypedData" + case walletIsValidTronMessageSignature = "wallet.isValidTronMessageSignature" + case walletIsValidTronTypedDataSignature = "wallet.isValidTronTypedDataSignature" case walletSendTransaction = "wallet.sendTransaction" case walletSendSolanaTransfer = "wallet.sendSolanaTransfer" + case walletSendTronTransaction = "wallet.sendTronTransaction" + case walletCallTronContract = "wallet.callTronContract" case walletCallContract = "wallet.callContract" case walletExecute = "wallet.execute" case walletGetTransactionStatus = "wallet.getTransactionStatus" case walletTransactionStatus = "wallet.transactionStatus" case indexerGetBalances = "indexer.getBalances" case indexerGetSolanaBalances = "indexer.getSolanaBalances" + case indexerGetTronBalances = "indexer.getTronBalances" case indexerGetTransactionHistory = "indexer.getTransactionHistory" public init?(rawValue: String) } diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index 5267617..8257490 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -46,7 +46,7 @@ struct SessionExpiredPrompt: Identifiable { let event: OMSWalletSessionExpiredEvent var email: String? { - event.session.auth?.email + event.session.auth.email } } @@ -255,6 +255,7 @@ final class AppViewModel: ObservableObject { @Published var sessionExpiredPrompt: SessionExpiredPrompt? @Published fileprivate var feeOptionSelectionRequest: FeeOptionSelectionRequest? @Published var useManualWalletSelection: Bool = false + @Published var selectedWalletType: DemoWalletType = .ethereum @Published var loginEmail: String = "" @Published var sessionLifetimeText: String = "604800" @Published var omsWallet: OMSWallet = try! OMSWallet( @@ -272,6 +273,10 @@ final class AppViewModel: ObservableObject { useManualWalletSelection ? .manual : .automatic } + private var walletType: WalletType { + selectedWalletType.walletType + } + var sessionLifetimeSeconds: UInt32? { let trimmed = sessionLifetimeText.trimmingCharacters(in: .whitespacesAndNewlines) guard let seconds = UInt32(trimmed), @@ -283,7 +288,7 @@ final class AppViewModel: ObservableObject { } func checkSession() async { - let hasSession = omsWallet.wallet.walletAddress != nil + let hasSession = omsWallet.wallet.activeWallet != nil screen = hasSession ? .wallet : .introduction } @@ -343,6 +348,7 @@ final class AppViewModel: ObservableObject { idToken: idToken, issuer: googleIssuer, audience: googleWebClientId, + walletType: walletType, walletSelection: walletSelectionBehavior, sessionLifetimeSeconds: lifetimeSeconds, provider: "google", @@ -432,6 +438,7 @@ final class AppViewModel: ObservableObject { let started = try await omsWallet.wallet.startOIDCRedirectAuth( provider: provider, omsRelayReturnURI: oidcRedirectUri, + walletType: walletType, walletSelection: walletSelectionBehavior, sessionLifetimeSeconds: lifetimeSeconds ) @@ -480,7 +487,8 @@ final class AppViewModel: ObservableObject { do { let result = try await omsWallet.wallet.completeEmailAuth( code: code, - walletSelection: walletSelectionBehavior + walletSelection: walletSelectionBehavior, + walletType: walletType ) switch result { case .walletSelected: @@ -553,7 +561,7 @@ final class AppViewModel: ObservableObject { } loginEmail = email await submitLogin() - case .oidc, nil: + case .oidc: screen = .login } } @@ -567,7 +575,7 @@ final class AppViewModel: ObservableObject { safariAuthSession = nil feeOptionSelectionRequest?.cancel() feeOptionSelectionRequest = nil - if let email = event.session.auth?.email { + if let email = event.session.auth.email { loginEmail = email } screen = .login @@ -769,6 +777,9 @@ struct LoginWindow: View { } .padding(.top, 16) + WalletTypePicker() + .padding(.top, 16) + ManualWalletSelectionToggle() .padding(.top, 16) @@ -914,7 +925,7 @@ struct WalletSelectionWindow: View { WalletSelectionRow( title: "No \(walletTypeLabel) wallets", subtitle: "Create a wallet to continue", - leadingText: "0x", + leadingText: walletLeadingText, isEnabled: false ) } else { @@ -925,7 +936,7 @@ struct WalletSelectionWindow: View { WalletSelectionRow( title: shortWalletAddress(wallet.address), subtitle: walletSelectionSubtitle(wallet), - leadingText: "0x" + leadingText: walletLeadingText ) } .buttonStyle(.plain) @@ -958,6 +969,10 @@ struct WalletSelectionWindow: View { pendingSelection.walletType.wireValue } + private var walletLeadingText: String { + pendingSelection.walletType == .tron ? "T" : "0x" + } + private func walletSelectionSubtitle(_ wallet: Wallet) -> String { [ nonEmpty(wallet.reference), @@ -981,6 +996,36 @@ struct WalletSelectionWindow: View { } } +enum DemoWalletType: String, CaseIterable, Identifiable { + case ethereum = "Ethereum" + case tron = "Tron" + + var id: String { rawValue } + + var walletType: WalletType { + switch self { + case .ethereum: .ethereum + case .tron: .tron + } + } +} + +private struct WalletTypePicker: View { + @EnvironmentObject private var vm: AppViewModel + + var body: some View { + FieldGroup(title: "Wallet type", titleStyle: .secondary) { + Picker("Wallet type", selection: $vm.selectedWalletType) { + ForEach(DemoWalletType.allCases) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + } +} + private struct ManualWalletSelectionToggle: View { @EnvironmentObject private var vm: AppViewModel @@ -1066,7 +1111,7 @@ struct WalletWindow: View { } private func refreshBalance() async { - guard let walletAddress = vm.omsWallet.wallet.walletAddress else { return } + guard let walletAddress = vm.omsWallet.wallet.activeWallet?.address else { return } isFetchingBalance = true clearBalance() defer { isFetchingBalance = false } @@ -1105,6 +1150,14 @@ struct WalletWindow: View { } var body: some View { + if vm.omsWallet.wallet.activeWallet?.type == .tron { + TronWalletWindow() + } else { + evmWalletTabs + } + } + + private var evmWalletTabs: some View { TabView { walletTab .tabItem { @@ -1183,7 +1236,7 @@ struct WalletWindow: View { private var walletAddressBar: some View { VStack(spacing: 8) { HStack(spacing: 6) { - Text(collapsedAddress(vm.omsWallet.wallet.walletAddress ?? "")) + Text(collapsedAddress(vm.omsWallet.wallet.activeWallet?.address ?? "")) .font(.system(size: 22, weight: .semibold, design: .monospaced)) .foregroundStyle(DesignTokens.Color.primaryText) .lineLimit(1) @@ -1191,7 +1244,7 @@ struct WalletWindow: View { .textSelection(.enabled) Button { - guard let walletAddress = vm.omsWallet.wallet.walletAddress else { return } + guard let walletAddress = vm.omsWallet.wallet.activeWallet?.address else { return } Clipboard.copy(walletAddress) didCopy = true Task { @@ -1204,7 +1257,7 @@ struct WalletWindow: View { .foregroundStyle(didCopy ? DesignTokens.Color.success : DesignTokens.Color.info) } .buttonStyle(.plain) - .disabled(vm.omsWallet.wallet.walletAddress == nil) + .disabled(vm.omsWallet.wallet.activeWallet == nil) .help(didCopy ? "Copied" : "Copy address") } @@ -1352,7 +1405,7 @@ struct WalletWindow: View { } private var sessionSubtitle: String { - sessionAuthLabel(vm.omsWallet.wallet.session.auth) + sessionAuthLabel(vm.omsWallet.wallet.session?.auth) } } @@ -1374,6 +1427,425 @@ private func sessionAuthLabel(_ auth: OMSWalletSessionAuth?) -> String { } } +// MARK: - Tron Wallet Window + +private let tronscanNileURL = "https://nile.tronscan.org/#" +private let nileFaucetURL = URL(string: "https://nileex.io/join/getJoinPage")! +private let nileUsdtContract = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" +private let nileUsdtDecimals = 6 +private let trxDecimals = 6 + +private func tronTransactionExplorerURL(_ txnHash: String) -> URL? { + URL(string: "\(tronscanNileURL)/transaction/\(txnHash)") +} + +private func tronAddressExplorerURL(_ address: String) -> URL? { + URL(string: "\(tronscanNileURL)/address/\(address)") +} + +private func formatTronAmount(_ raw: String, decimals: Int) -> String { + trimBalanceDisplay((try? formatUnits(value: raw, decimals: decimals)) ?? raw, maxFractionDigits: decimals) +} + +struct TronWalletWindow: View { + @EnvironmentObject private var vm: AppViewModel + + var body: some View { + TabView { + TronBalancesWindow() + .tabItem { + Label("Wallet", systemImage: "wallet.pass") + } + + TronSendWindow() + .tabItem { + Label("Send", systemImage: "arrow.up.circle") + } + + TronSignMessageWindow() + .tabItem { + Label("Sign", systemImage: "signature") + } + } + .tint(DesignTokens.Color.info) + #if os(macOS) + .frame(minWidth: 640, minHeight: 560) + #endif + } +} + +private struct TronBalancesWindow: View { + @EnvironmentObject private var vm: AppViewModel + + @State private var trxBalance: String = "—" + @State private var usdtBalance: String = "—" + @State private var balanceStatus: String? + @State private var isFetchingBalance: Bool = false + @State private var error: GenericAppError? + + var body: some View { + ModalContainer( + title: "Tron wallet", + subtitle: "Nile testnet. Tron wallets use native mode only. Transactions are often sponsored by each account's daily free bandwidth; once it is spent, the network fee is paid in TRX.", + showsCloseButton: false + ) { + if let address = vm.omsWallet.wallet.activeWallet?.address { + FieldGroup(title: "Address") { + CopyableResult(text: address) + if let url = tronAddressExplorerURL(address) { + Link("View wallet on Tronscan", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + + DesignText(sessionAuthLabel(vm.omsWallet.wallet.session?.auth), variant: .caption) + + Panel { + tronBalanceRow(title: "Nile TRX balance", value: "\(trxBalance) TRX", faucetTitle: "Open TRX faucet") + tronBalanceRow(title: "Nile USDT balance", value: "\(usdtBalance) USDT", faucetTitle: "Open USDT faucet") + + if let balanceStatus { + DesignText(balanceStatus, variant: .error) + } + } + + Button { + Task { await refreshBalances() } + } label: { + label(for: "Refresh balances", systemImage: "arrow.clockwise", loading: isFetchingBalance) + } + .buttonStyle(DesignButtonStyle(variant: .secondary)) + .disabled(isFetchingBalance) + + Button { + vm.signOut() + } label: { + label(for: "Sign out", systemImage: "rectangle.portrait.and.arrow.right", loading: false) + } + .buttonStyle(DesignButtonStyle(variant: .secondary)) + } + .genericErrorWindow(error: $error) + .task { + await refreshBalances() + } + } + + private func tronBalanceRow(title: String, value: String, faucetTitle: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + DesignText(title, variant: .caption) + Link(faucetTitle, destination: nileFaucetURL) + .font(.caption.weight(.semibold)) + } + + Spacer() + + if isFetchingBalance { + ProgressView() + .controlSize(.small) + .tint(DesignTokens.Color.info) + } else { + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(DesignTokens.Color.primaryText) + .monospacedDigit() + } + } + } + + private func refreshBalances() async { + guard let address = vm.omsWallet.wallet.activeWallet?.address else { return } + isFetchingBalance = true + balanceStatus = nil + defer { isFetchingBalance = false } + + do { + let result = try await vm.omsWallet.indexer.getTronBalances( + GetTronBalancesParams( + walletAddress: address, + networks: [.nile], + contractAddresses: [nileUsdtContract] + ) + ) + if let networkError = result.errors.first(where: { $0.network == .nile }) { + balanceStatus = networkError.reason + return + } + + var trxRaw = "0" + var usdtRaw = "0" + for balance in result.balances { + switch balance { + case .native(let native): + trxRaw = native.balance + case .fungibleToken(let token) where token.contractAddress == nileUsdtContract: + usdtRaw = token.balance + case .fungibleToken: + break + } + } + trxBalance = formatTronAmount(trxRaw, decimals: trxDecimals) + usdtBalance = formatTronAmount(usdtRaw, decimals: nileUsdtDecimals) + } catch { + trxBalance = "—" + usdtBalance = "—" + self.error = GenericAppError(error) + } + } +} + +private enum TronTransferAsset: String, CaseIterable, Identifiable { + case trx = "TRX" + case usdt = "USDT" + case custom = "Custom TRC-20" + + var id: String { rawValue } +} + +private struct TronSendWindow: View { + @EnvironmentObject private var vm: AppViewModel + + @State private var asset: TronTransferAsset = .trx + @State private var recipientText: String = "" + @State private var amountText: String = "1" + @State private var tokenContractText: String = "" + @State private var tokenDecimalsText: String = "6" + @State private var result: SendTransactionResponse? + @State private var transferStatus: String? + @State private var isSending: Bool = false + @State private var error: GenericAppError? + + var body: some View { + ModalContainer( + title: "Send on Tron Nile", + subtitle: "Send TRX or TRC-20 tokens. TRX transfers use sendTronTransaction; token transfers call the contract's transfer method.", + showsCloseButton: false + ) { + FieldGroup(title: "Asset") { + Picker("Asset", selection: $asset) { + ForEach(TronTransferAsset.allCases) { asset in + Text(asset.rawValue).tag(asset) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + if asset == .usdt { + DesignText("USDT contract address: \(nileUsdtContract)", variant: .caption) + .textSelection(.enabled) + } + } + + if asset == .custom { + FieldGroup(title: "Token contract") { + TextField("TRC-20 contract address (T...)", text: $tokenContractText) + .tokenTextInput() + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + + FieldGroup(title: "Token decimals") { + TextField("6", text: $tokenDecimalsText) + .tokenTextInput() + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + } + + FieldGroup(title: "Recipient wallet") { + TextField("Tron wallet address (T...)", text: $recipientText) + .tokenTextInput() + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + + FieldGroup(title: amountTitle) { + TextField("Enter amount", text: $amountText) + .tokenTextInput() + #if os(iOS) + .keyboardType(.decimalPad) + #endif + } + + Button { + Task { await sendTransfer() } + } label: { + label(for: "Send on Tron Nile", systemImage: "paperplane", loading: isSending) + } + .buttonStyle(DesignButtonStyle(variant: .primary)) + .disabled(!canSend || isSending) + + if let transferStatus { + DesignText(transferStatus, variant: .body) + } + + if let result { + TransactionResultPanel(result: result) + + if let txnHash = result.txnHash, !txnHash.isEmpty, + let url = tronTransactionExplorerURL(txnHash) { + Link("View on Tronscan", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + .genericErrorWindow(error: $error) + .sheet(item: $vm.feeOptionSelectionRequest) { request in + FeeOptionSelectionWindow(request: request) + .environmentObject(vm) + } + } + + private var amountTitle: String { + switch asset { + case .trx: "Amount (TRX)" + case .usdt: "Amount (USDT)" + case .custom: "Amount (token units)" + } + } + + private var canSend: Bool { + let hasRequiredFields = !recipientText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !amountText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard asset == .custom else { return hasRequiredFields } + return hasRequiredFields + && !tokenContractText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && tokenDecimals != nil + } + + private var tokenDecimals: Int? { + guard let decimals = Int(tokenDecimalsText.trimmingCharacters(in: .whitespacesAndNewlines)), + (0...255).contains(decimals) else { + return nil + } + return decimals + } + + private func sendTransfer() async { + let recipient = recipientText.trimmingCharacters(in: .whitespacesAndNewlines) + let amount = amountText.trimmingCharacters(in: .whitespacesAndNewlines) + isSending = true + result = nil + transferStatus = "Preparing transfer..." + defer { isSending = false } + + let selectFeeOption = FeeOptionSelector.custom { options in + try await vm.selectFeeOption(options) + } + let statusPolling = TransactionStatusPollingOptions(timeoutMs: 120_000) + + do { + let transaction: SendTransactionResponse + switch asset { + case .trx: + transaction = try await vm.omsWallet.wallet.sendTronTransaction( + network: .nile, + to: recipient, + value: try parseUnits(value: amount, decimals: trxDecimals), + selectFeeOption: selectFeeOption, + statusPolling: statusPolling + ) + case .usdt, .custom: + let contract = asset == .usdt + ? nileUsdtContract + : tokenContractText.trimmingCharacters(in: .whitespacesAndNewlines) + let decimals = asset == .usdt ? nileUsdtDecimals : (tokenDecimals ?? 0) + // TRC-20 transfers are contract calls; the wallet service ABI-encodes the arguments. + transaction = try await vm.omsWallet.wallet.callTronContract( + network: .nile, + contract: contract, + method: "transfer", + args: [ + AbiArg(type: "address", value: .string(recipient)), + AbiArg(type: "uint256", value: .string(try parseUnits(value: amount, decimals: decimals))) + ], + selectFeeOption: selectFeeOption, + statusPolling: statusPolling + ) + } + + result = transaction + // Tron reports `pending` with a hash until the block is solidified (about a minute). + transferStatus = transaction.status == .executed || transaction.status == .failed + ? "Transfer \(transaction.status.wireValue)." + : "Transaction submitted." + } catch { + transferStatus = nil + self.error = GenericAppError(error) + } + } +} + +private struct TronSignMessageWindow: View { + @EnvironmentObject private var vm: AppViewModel + + @State private var messageText: String = "Sign in to OMS Wallet" + @State private var signature: String = "" + @State private var signStatus: String? + @State private var isSigning: Bool = false + @State private var error: GenericAppError? + + var body: some View { + ModalContainer( + title: "Sign Tron message", + subtitle: "Sign a message with the Tron wallet and verify the signature.", + showsCloseButton: false + ) { + FieldGroup(title: "Message") { + TextField("Enter message", text: $messageText) + .tokenTextInput() + } + + Button { + Task { await signAndVerify() } + } label: { + label(for: "Sign Tron message", systemImage: "signature", loading: isSigning) + } + .buttonStyle(DesignButtonStyle(variant: .primary)) + .disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSigning) + + if !signature.isEmpty { + ResultPanel(title: "Signature", text: signature) + } + + if let signStatus { + DesignText(signStatus, variant: .body) + } + } + .genericErrorWindow(error: $error) + } + + private func signAndVerify() async { + guard let walletAddress = vm.omsWallet.wallet.activeWallet?.address else { return } + let message = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + isSigning = true + signature = "" + signStatus = "Signing message..." + defer { isSigning = false } + + do { + let signed = try await vm.omsWallet.wallet.signTronMessage(message: message) + signature = signed + signStatus = "Verifying signature..." + let isValid = try await vm.omsWallet.wallet.isValidTronMessageSignature( + walletAddress: walletAddress, + message: message, + signature: signed + ) + signStatus = isValid ? "Message signed and verified." : "Signature verification failed." + } catch { + signStatus = nil + self.error = GenericAppError(error) + } + } +} + // MARK: - Sign Message Window struct SignMessageWindow: View { @@ -1521,7 +1993,7 @@ struct SendTransactionWindow: View { .environmentObject(vm) } .onAppear { - if toText.isEmpty, let walletAddress = vm.omsWallet.wallet.walletAddress { + if toText.isEmpty, let walletAddress = vm.omsWallet.wallet.activeWallet?.address { toText = walletAddress } } diff --git a/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift b/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift index 969cba4..769548d 100644 --- a/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift +++ b/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift @@ -13,21 +13,35 @@ private final class TrailsOMSStorage: @unchecked Sendable { } } +struct TrailsWalletSession: Sendable { + let activeWallet: Wallet? + let session: OMSWalletSession? + + static let signedOut = TrailsWalletSession(activeWallet: nil, session: nil) + + var auth: OMSWalletSessionAuth? { + session?.auth + } +} + actor TrailsOMSWallet { private let storage: TrailsOMSStorage private var tail: Task? - nonisolated let initialSession: OMSWalletSessionState + nonisolated let initialSession: TrailsWalletSession init(publishableKey: String) { let storage = TrailsOMSStorage(publishableKey: publishableKey) self.storage = storage - self.initialSession = storage.omsWallet.wallet.session + self.initialSession = TrailsWalletSession( + activeWallet: storage.omsWallet.wallet.activeWallet, + session: storage.omsWallet.wallet.session + ) } - func session() async -> OMSWalletSessionState { + func session() async -> TrailsWalletSession { await perform { client in - client.wallet.session + TrailsWalletSession(activeWallet: client.wallet.activeWallet, session: client.wallet.session) } } @@ -139,9 +153,8 @@ actor TrailsOMSWallet { private func wrap(_ result: CompleteAuthResult) -> TrailsCompleteAuthResult { switch result { - case .walletSelected(let walletAddress, let wallet, let wallets, let credential): + case .walletSelected(let wallet, let wallets, let credential): return .walletSelected( - walletAddress: walletAddress, wallet: wallet, wallets: wallets, credential: credential @@ -197,7 +210,6 @@ actor TrailsOMSWallet { enum TrailsCompleteAuthResult: Sendable { case walletSelected( - walletAddress: String, wallet: Wallet, wallets: [Wallet], credential: WalletCredential @@ -238,7 +250,7 @@ final class TrailsPendingWalletSelection: @unchecked Sendable { @MainActor final class TrailsDemoViewModel: ObservableObject { - @Published var session = OMSWalletSessionState(walletAddress: nil) + @Published var session = TrailsWalletSession.signedOut @Published var authStep: AuthStep = .email @Published var email = "" @Published var code = "" @@ -302,8 +314,12 @@ final class TrailsDemoViewModel: ObservableObject { } } + // Trails actions are EVM-only, so only an active Ethereum wallet counts as signed in. var walletAddress: String? { - session.walletAddress + guard let activeWallet = session.activeWallet, activeWallet.type == .ethereum else { + return nil + } + return activeWallet.address } var isSignedIn: Bool { @@ -874,9 +890,9 @@ final class TrailsDemoViewModel: ObservableObject { authStatus = status redirectStatus = "" await refreshSession() - appendLog("Wallet ready: \(result.walletAddress)") - await refreshBalances(walletAddress: result.walletAddress) - await refreshEarnPositions(walletAddress: result.walletAddress) + appendLog("Wallet ready: \(result.wallet.address)") + await refreshBalances(walletAddress: result.wallet.address) + await refreshEarnPositions(walletAddress: result.wallet.address) } @discardableResult diff --git a/MIGRATION.md b/MIGRATION.md index 96e356e..c96322e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,6 +3,96 @@ This document records breaking changes and the steps to migrate between published versions of `oms-wallet-swift-sdk`. +## 0.4.0 + +### Active wallet replaces `walletAddress` + +`omsWallet.wallet.walletAddress` was removed. Read the active wallet from +`omsWallet.wallet.activeWallet`, a `Wallet` (`id`, `type`, `address`, `reference`, `keyOrigin`) or +`nil` when signed out. Check `type` before passing the address to family-specific code: + +```swift +// 0.3.x +let address = omsWallet.wallet.walletAddress + +// 0.4.0 +if let activeWallet = omsWallet.wallet.activeWallet, activeWallet.type == .ethereum { + let ethereumAddress = activeWallet.address +} +``` + +`walletAddress` was also removed from `WalletSelectionResult` and from +`CompleteAuthResult.walletSelected`, which is now `.walletSelected(wallet:wallets:credential:)`. +Use `result.wallet.address`: + +```swift +// 0.3.x +case .walletSelected(let walletAddress, let wallet, let wallets, let credential): + +// 0.4.0 +case .walletSelected(let wallet, let wallets, let credential): + let walletAddress = wallet.address +``` + +### `session` is `nil` when signed out + +`OMSWalletSessionState` was renamed to `OMSWalletSession`, and `omsWallet.wallet.session` is now +`OMSWalletSession?`. Previously it returned a value whose fields were all `nil`. The +`walletAddress` field was removed, and `expiresAt` and `auth` are no longer optional. `session` is +non-`nil` exactly when `activeWallet` is. + +```swift +// 0.3.x +let email = omsWallet.wallet.session.auth?.email + +// 0.4.0 +let email = omsWallet.wallet.session?.auth.email +``` + +`OMSWalletSessionExpiredEvent` gained `wallet: Wallet?`, and its `session` no longer carries +`walletAddress`. `wallet` is `nil` when the credential expired while a manual wallet selection was +still pending. + +```swift +// 0.3.x +print(event.session.walletAddress ?? "unknown") + +// 0.4.0 +print(event.wallet?.address ?? "no wallet selected") +``` + +### One-time sign-in after upgrading + +Saved sessions now record the full wallet, including its type. Sessions saved by 0.3.x do not, so +0.4.0 discards them on launch and users sign in once after upgrading. + +### Contract method names + +`callContract` (and the new `callTronContract`) now reject a `method` that is not a bare function +name, such as `"transfer(address,uint256)"`, with `.validationError` before sending a request. The +wallet service builds the signature from the argument types, so pass only the name: + +```swift +// 0.3.x +method: "transfer(address,uint256)" + +// 0.4.0 +method: "transfer" +``` + +### Wallet responses + +Ethereum wallets whose address is not `0x` followed by 40 hexadecimal digits are now rejected with +`.invalidResponse`. Checksum casing is not enforced. + +### Exhaustive switches + +`WalletType` gained `.tron`, and `WalletImportPrivateKey` gained `.tron(_:)` and `.tronBytes(_:)`. +`OMSWalletOperation` gained `.walletSignTronMessage`, `.walletSignTronTypedData`, +`.walletIsValidTronMessageSignature`, `.walletIsValidTronTypedDataSignature`, +`.walletSendTronTransaction`, `.walletCallTronContract`, and `.indexerGetTronBalances`. Update +exhaustive switches over these enums. + ## 0.3.0 ### Wallet types and key origin diff --git a/README.md b/README.md index 4aac4bf..2094089 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,14 @@ Pass your OMS publishable key when creating the client. The SDK derives the wall Wallet API requests are signed with a non-extractable Keychain P-256 credential using the `webcrypto-secp256r1` key type. The credential remains Keychain-managed and is not serialized into SDK session storage. -Only completed wallet session metadata is restored automatically, including wallet address, expiry, and auth metadata such as email or OIDC issuer/provider details when available. The SDK checks the cached session expiry before restoring a session. Expired sessions are not activated, and invalid session metadata is cleared; expired session metadata remains in storage as a reauth hint until `signOut()` or a new auth flow clears or replaces it. +Only completed wallet session metadata is restored automatically, including the active wallet (ID, type, address, reference, and key origin), expiry, and auth metadata such as email or OIDC issuer/provider details when available. The SDK checks the cached session expiry before restoring a session. Expired sessions are not activated, and invalid session metadata is cleared; expired session metadata remains in storage as a reauth hint until `signOut()` or a new auth flow clears or replaces it. ## Authentication Flow OMS supports email-based OTP, OIDC ID-token auth, and OIDC redirect auth. The email two-step flow is: 1. **`startEmailAuth(email:sessionLifetimeSeconds:)`** validates the requested session lifetime and sends a one-time code to the user's inbox. -2. **`completeEmailAuth(code:walletSelection:walletType:)`** verifies the code using the session lifetime chosen when the flow started. In the default `.automatic` mode it selects the first matching wallet or creates one. The wallet address, wallet ID, and signer metadata are saved to the device keychain. +2. **`completeEmailAuth(code:walletSelection:walletType:)`** verifies the code using the session lifetime chosen when the flow started. In the default `.automatic` mode it selects the first matching wallet or creates one. The active wallet and signer metadata are saved to the device keychain. ```swift try await omsWallet.wallet.startEmailAuth(email: "user@example.com") @@ -117,12 +117,19 @@ let result = try await omsWallet.wallet.completeEmailAuth(code: "123456") if let wallet = result.wallet { print(wallet.address) } -let session = omsWallet.wallet.session -print(session.walletAddress ?? "signed out") -if let expiresAt = session.expiresAt { print(expiresAt) } -print(session.auth?.email ?? "unknown") +if let activeWallet = omsWallet.wallet.activeWallet, + let session = omsWallet.wallet.session { + print(activeWallet.type, activeWallet.address) + print(session.expiresAt) + print(session.auth.email ?? "unknown") +} else { + print("signed out") +} ``` +`activeWallet` is a `Wallet`, the same type `listWallets()` returns, or `nil` when signed out. +`session` holds the expiry and auth metadata and is non-`nil` exactly when `activeWallet` is. + Pass `sessionLifetimeSeconds` to `startEmailAuth` when you need a shorter or longer email session. The default is one week, and custom values must be from 1 through 2,592,000 seconds (30 days). The SDK validates the value before sending @@ -131,7 +138,8 @@ to session expiry: ```swift let sessionExpiredObservation = omsWallet.wallet.addSessionExpiredObserver { event in - print("Session expired:", event.session.walletAddress ?? "unknown") + // `wallet` is nil when the credential expired during a pending manual wallet selection. + print("Session expired:", event.wallet?.address ?? "no wallet selected", event.expiredAt) } // Later, when the observer is no longer needed: @@ -179,7 +187,7 @@ if let wallet = result.wallet { Use `walletSelection: .manual` when the app should present its own wallet picker. Pass `provider` and `providerLabel` for custom ID-token providers when -those labels should be stored in `omsWallet.wallet.session.auth`. +those labels should be stored in `omsWallet.wallet.session?.auth`. For OIDC authorization-code redirect flows, start the redirect, open the returned URL with your browser UI, then safely handle incoming app links. @@ -199,7 +207,7 @@ do { callbackURLString ) switch result { - case .completed(.walletSelected(_, let wallet, let wallets, let credential)): + case .completed(.walletSelected(let wallet, let wallets, let credential)): print(wallet.address, wallets.count, credential.credentialId) case .completed(.walletSelection(let pendingSelection)): // Show pendingSelection.wallets in your app UI. @@ -296,8 +304,8 @@ let imported = try await omsWallet.wallet.importWallet( print(imported.wallet.keyOrigin == .imported) ``` -Ethereum imports accept a 32-byte raw scalar or 64 hexadecimal digits, optionally prefixed with -`0x`. Solana imports accept a 32-byte seed or 64-byte keypair as raw bytes, or the base58 encoding +Ethereum and Tron imports (both secp256k1) accept a 32-byte raw scalar or 64 hexadecimal digits, +optionally prefixed with `0x`, through `.ethereum`/`.ethereumBytes` or `.tron`/`.tronBytes`. Solana imports accept a 32-byte seed or 64-byte keypair as raw bytes, or the base58 encoding of either. The SDK does not persist plaintext imported keys. For caller-managed HPKE flows, use `getWalletImportRecipientKey(cipherSuite:)` and then `importEncryptedWallet(walletType:keyMaterial:reference:)`; both responses remain attestation @@ -313,7 +321,7 @@ let signature = try await omsWallet.wallet.signMessage( message: "hello from OMS Wallet" ) -guard let walletAddress = omsWallet.wallet.walletAddress else { return } +guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } let isValid = try await omsWallet.wallet.isValidMessageSignature( network: .polygonAmoy, @@ -327,7 +335,8 @@ For a selected Solana wallet, use the Solana-specific message methods; off-chain require a cluster: ```swift -guard let solanaWalletAddress = omsWallet.wallet.walletAddress else { return } +guard let activeWallet = omsWallet.wallet.activeWallet, activeWallet.type == .solana else { return } +let solanaWalletAddress = activeWallet.address let signature = try await omsWallet.wallet.signSolanaMessage(message: "hello from Solana") let valid = try await omsWallet.wallet.isValidSolanaMessageSignature( @@ -365,7 +374,7 @@ let signature = try await omsWallet.wallet.signTypedData( typedData: typedData ) -guard let walletAddress = omsWallet.wallet.walletAddress else { return } +guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } let typedDataValid = try await omsWallet.wallet.isValidTypedDataSignature( network: .polygonAmoy, @@ -378,7 +387,7 @@ let typedDataValid = try await omsWallet.wallet.isValidTypedDataSignature( ### Query Balances ```swift -guard let walletAddress = omsWallet.wallet.walletAddress else { return } +guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } let result = try await omsWallet.indexer.getBalances( GetBalancesParams( @@ -425,7 +434,7 @@ print(balance?.balance ?? "0") ### Query Transaction History ```swift -guard let walletAddress = omsWallet.wallet.walletAddress else { return } +guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } let history = try await omsWallet.indexer.getTransactionHistory( GetTransactionHistoryParams( @@ -498,7 +507,7 @@ let amount = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.callContract( network: .polygonAmoy, contract: "0x3333333333333333333333333333333333333333", - method: "transfer(address,uint256)", + method: "transfer", args: [ AbiArg(type: "address", value: .string("0x1111111111111111111111111111111111111111")), AbiArg(type: "uint256", value: .string(amount)), @@ -506,6 +515,10 @@ let txResult = try await omsWallet.wallet.callContract( ) ``` +Pass the bare function name, such as `"transfer"`, not a signature such as +`"transfer(address,uint256)"`. The wallet service builds the signature from the argument types, and +the SDK rejects any other value with `.validationError` before sending a request. + To return immediately after execute without status polling, pass `waitForStatus: false`. You can then call `getTransactionStatus` with the returned `txnId`. The immediate response has `statusResolution == .notRequested`. @@ -570,15 +583,88 @@ let txResult = try await omsWallet.wallet.sendTransaction( ``` Custom selectors receive `FeeOptionWithBalance` values. For Ethereum fees, `balance` -contains the matching `TokenBalance` when available. For both Ethereum and Solana fees, +contains the matching `TokenBalance` when available. For Ethereum, Solana, and Tron fees, `available` is formatted with the token decimals, `availableRaw` is the raw integer balance, and `decimals` is the token decimal count used for formatting. This lets -`.firstAvailable` select the first affordable option on either network family. Unsponsored +`.firstAvailable` select the first affordable option on any network family. Unsponsored transactions require the selector to return a fee selection. Sponsored transactions invoke the selector with an empty array; return `nil` after acknowledging the free fee, or throw to stop execution. `.firstAvailable` returns `nil` for that empty array and continues execution as before. +### Tron Wallets + +Tron wallets are EOAs and always execute in native mode, so the Tron methods take no `mode` +parameter. Create one with `createWallet(walletType: .tron)`, sign in with `walletType: .tron`, or +import a secp256k1 private key with `.tron(_:)`. Tron addresses are Base58Check strings (`T…`). +Supported networks are `TronNetwork.mainnet` and `TronNetwork.nile`. TRC-10 tokens are not +supported. + +```swift +let created = try await omsWallet.wallet.createWallet(walletType: .tron) + +let signature = try await omsWallet.wallet.signTronMessage(message: "some message to sign") +let isValid = try await omsWallet.wallet.isValidTronMessageSignature( + walletAddress: created.wallet.address, + message: "some message to sign", + signature: signature +) + +// TRX transfer. Values are in sun (1 TRX = 1,000,000 sun). +let trxTransfer = try await omsWallet.wallet.sendTronTransaction( + network: .nile, + to: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + value: try parseUnits(value: "1", decimals: 6) +) + +// TRC-20 transfer. The wallet service ABI-encodes the call; address arguments accept `T…`. +let trc20Transfer = try await omsWallet.wallet.callTronContract( + network: .nile, + contract: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + method: "transfer", + args: [ + AbiArg(type: "address", value: .string("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")), + AbiArg(type: "uint256", value: .string("1000000")), + ] +) +``` + +`signTronTypedData(typedData:)` and `isValidTronTypedDataSignature(walletAddress:typedData:signature:)` +sign and verify TIP-712 typed data, whose address values may be Base58Check. + +Omitting `data` from `sendTronTransaction` sends a plain TRX transfer. Passing `data`, even `"0x"`, +makes the transaction a contract call: `"0x"` calls the recipient contract's payable fallback. + +Tron transactions report `.pending` with a `txnHash` until the block is solidified (about a +minute), so status polling returns as soon as the hash is available. + +Every Tron account gets a daily free bandwidth allowance (600 points, about two TRX transfers), so +prepared Tron transactions are often sponsored even without a relayer. When the allowance is spent, +WaaS quotes the TRX to burn as a single native fee option, which a fee selector receives like any +other fee option. + +Use `getTronBalances` for TRX and TRC-20 balances. Omit `networks` to query both Tron Mainnet and +Nile, or pass either network explicitly. Results have the same shape as `getSolanaBalances`: +precision-safe raw and formatted balance strings, with TRC-20 entries identified by +`contractAddress`. Pass `contractAddresses` or `excludedContractAddresses` to filter tokens. +Individual network failures are reported in `errors` without discarding balances returned by the +other requested network. + +```swift +let balances = try await omsWallet.indexer.getTronBalances( + GetTronBalancesParams(walletAddress: created.wallet.address, networks: [.nile]) +) + +for balance in balances.balances { + switch balance { + case .native(let trx): + print(trx.network, trx.symbol, trx.formattedBalance) + case .fungibleToken(let token): + print(token.network, token.contractAddress, token.symbol, token.formattedBalance) + } +} +``` + ## Advanced Configuration The SDK derives API endpoints from the publishable key. Use the key prefix for @@ -635,6 +721,13 @@ Solana uses the separate `SolanaNetwork` type, not `Network`: `SolanaNetwork.mainnet` is distinct from `Network.mainnet`, which is Ethereum mainnet. +Tron uses the separate `TronNetwork` type: + +| Swift case | Raw value | +|---|---| +| `TronNetwork.mainnet` | `tron:mainnet` | +| `TronNetwork.nile` | `tron:nile` | + ## Unit Formatting Use the top-level helpers to convert between display amounts and base-unit integer strings without floating-point precision loss. Fractional precision beyond `decimals` is rounded to the nearest base unit. diff --git a/Sources/OMSWallet/Clients/IndexerClient.swift b/Sources/OMSWallet/Clients/IndexerClient.swift index e5930d2..6c2fd5a 100644 --- a/Sources/OMSWallet/Clients/IndexerClient.swift +++ b/Sources/OMSWallet/Clients/IndexerClient.swift @@ -2,8 +2,10 @@ import Foundation private let indexerGatewayWebRPCHeaderValue = "webrpc@v0.31.2;gen-swift@v0.1.2;sequence-indexer@v0.4.0" private let solanaIndexerGatewayWebRPCHeaderValue = "webrpc@v0.31.2;gen-swift@v0.1.2;solana-indexer-gateway@v1" +private let tronIndexerGatewayWebRPCHeaderValue = "webrpc@v0.31.2;gen-swift@v0.1.2;tron-indexer-gateway@v1" -private struct SolanaBalancesFilter: Encodable { +/// Shared by the Solana and Tron indexer gateways, which take the same balance filter. +private struct GatewayBalancesFilter: Encodable { let accountAddresses: [String] let omitNativeBalances: Bool? let contractWhitelist: [String]? @@ -12,7 +14,7 @@ private struct SolanaBalancesFilter: Encodable { private struct GetSolanaBalancesRequest: Encodable { let networks: [SolanaNetwork] - let filter: SolanaBalancesFilter + let filter: GatewayBalancesFilter let omitMetadata: Bool } @@ -21,6 +23,17 @@ private struct GetSolanaBalancesResponse: Decodable { let errors: [SolanaNetworkError] } +private struct GetTronBalancesRequest: Encodable { + let networks: [TronNetwork] + let filter: GatewayBalancesFilter + let omitMetadata: Bool +} + +private struct GetTronBalancesResponse: Decodable { + let balances: [TronBalance] + let errors: [TronNetworkError] +} + private struct GatewayNativeTokenBalances: Decodable { let chainId: Int64 let results: [NativeTokenBalance] @@ -141,7 +154,7 @@ public final class IndexerClient: Sendable { try await runOMSWalletOperation(.indexerGetSolanaBalances) { let request = GetSolanaBalancesRequest( networks: params.networks, - filter: SolanaBalancesFilter( + filter: GatewayBalancesFilter( accountAddresses: [params.walletAddress], omitNativeBalances: params.omitNativeBalances, contractWhitelist: nonEmpty(params.mintAddresses), @@ -165,6 +178,34 @@ public final class IndexerClient: Sendable { } } + public func getTronBalances(_ params: GetTronBalancesParams) async throws -> TronBalancesResult { + try await runOMSWalletOperation(.indexerGetTronBalances) { + let request = GetTronBalancesRequest( + networks: params.networks, + filter: GatewayBalancesFilter( + accountAddresses: [params.walletAddress], + omitNativeBalances: params.omitNativeBalances, + contractWhitelist: nonEmpty(params.contractAddresses), + contractBlacklist: nonEmpty(params.excludedContractAddresses) + ), + omitMetadata: !params.includeMetadata + ) + let response = try await postJson( + operation: .indexerGetTronBalances, + baseUrl: environment.tronIndexerGatewayUrl, + path: "/GetTokenBalancesDetails", + request: request, + responseType: GetTronBalancesResponse.self, + webRPCHeaderValue: tronIndexerGatewayWebRPCHeaderValue + ) + return TronBalancesResult( + status: response.statusCode, + balances: response.payload.balances, + errors: response.payload.errors + ) + } + } + private func postJson( operation: OMSWalletOperation, baseUrl: String? = nil, diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index 164094a..c2f8795 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -564,6 +564,9 @@ extension WalletClient { requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil ) async throws -> CompleteAuthResult { + guard OMSWalletSession.parseDate(response.credential.expiresAt) != nil else { + throw OMSWalletError(code: .invalidResponse, message: "Auth response has an invalid credential expiresAt") + } let sessionMetadata = SessionMetadata( expiresAt: response.credential.expiresAt, auth: sessionAuth @@ -645,7 +648,6 @@ extension WalletClient { } return .walletSelected( - walletAddress: activated.walletAddress, wallet: activated.wallet, wallets: candidateWallets.isEmpty ? wallets + [activated.wallet] : wallets, credential: response.credential.walletCredential @@ -719,14 +721,16 @@ extension WalletClient { guard activePendingWalletSelection?.id == selectionSession.id else { throw OMSWalletError.walletSelectionStale() } - let selectionSessionState = OMSWalletSessionState( - walletAddress: nil, - expiresAtString: selectionSession.metadata.expiresAt, - auth: selectionSession.metadata.auth - ) - guard !isSessionExpired(selectionSessionState) else { - expireSession(selectionSessionState) - throw OMSWalletError.sessionExpired() + if let expiresAt = OMSWalletSession.parseDate(selectionSession.metadata.expiresAt) { + let selectionSessionState = WalletSessionSnapshot( + wallet: nil, + expiresAt: expiresAt, + auth: selectionSession.metadata.auth + ) + guard !isSessionExpired(selectionSessionState) else { + expireSession(selectionSessionState) + throw OMSWalletError.sessionExpired() + } } try requireActiveCredential() let signerCredentialId = try credentialSession.signer.credentialId() @@ -769,7 +773,7 @@ extension WalletClient { /// Call this after `completeEmailAuth(code:walletSelection:walletType:)` returns /// `.walletSelection`, or when an authenticated session already exists. /// - /// - Parameter walletType: The wallet type to create: `.ethereum` (default) or `.solana`. + /// - Parameter walletType: The wallet type to create: `.ethereum` (default), `.solana`, or `.tron`. @discardableResult public func createWallet( walletType: WalletType = WalletType.ethereum, @@ -810,18 +814,15 @@ extension WalletClient { ) let response = try await signedClient.createWallet(params) + let wallet = try response.wallet.sdkValue try createSequenceWallet( - walletAddress: response.wallet.address, - walletId: response.wallet.id, + wallet: wallet, sessionMetadata: sessionMetadata, requiredSessionRevision: requiredSessionRevision, oidcRedirectAuthOwnership: oidcRedirectAuthOwnership ) - return WalletSelectionResult( - walletAddress: response.wallet.address, - wallet: try response.wallet.sdkValue - ) + return WalletSelectionResult(wallet: wallet) } /// Loads an existing wallet by ID for the authenticated user and persists @@ -842,18 +843,15 @@ extension WalletClient { ) let response = try await signedClient.useWallet(params) + let wallet = try response.wallet.sdkValue try createSequenceWallet( - walletAddress: response.wallet.address, - walletId: response.wallet.id, + wallet: wallet, sessionMetadata: sessionMetadata, requiredSessionRevision: requiredSessionRevision, oidcRedirectAuthOwnership: oidcRedirectAuthOwnership ) - return WalletSelectionResult( - walletAddress: response.wallet.address, - wallet: try response.wallet.sdkValue - ) + return WalletSelectionResult(wallet: wallet) } private func walletsFromAuthResponse(_ response: CompleteAuthResponse) async throws -> [Wallet] { diff --git a/Sources/OMSWallet/Clients/WalletClient.swift b/Sources/OMSWallet/Clients/WalletClient.swift index f591c11..0115f16 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -89,7 +89,7 @@ public final class WalletClient: @unchecked Sendable { withSessionLock { _latestSessionExpiredEvent = newValue } } } - private var _walletAddress: String + private var _activeWallet: Wallet? private var _walletId: String var _sessionRevision: UInt64 = 0 private var _sessionExpiredObservers: [UUID: SessionExpiredObserver] = [:] @@ -97,15 +97,14 @@ public final class WalletClient: @unchecked Sendable { private var _challenge = "" private var _pendingEmailAuth: PendingEmailAuth? - public internal(set) var walletAddress: String? { + /// The active wallet, or `nil` until auth completes or a session is restored, and after + /// sign-out or session expiry. Same shape as `listWallets()` entries. + public internal(set) var activeWallet: Wallet? { get { - withSessionLock { - let walletAddress = _walletAddress.trimmingCharacters(in: .whitespacesAndNewlines) - return walletAddress.isEmpty ? nil : walletAddress - } + withSessionLock { _activeWallet } } set { - withSessionLock { _walletAddress = newValue ?? "" } + withSessionLock { _activeWallet = newValue } } } public internal(set) var walletId: String { @@ -241,7 +240,7 @@ public final class WalletClient: @unchecked Sendable { } self._walletId = "" - self._walletAddress = "" + self._activeWallet = nil self._sessionExpiresAt = nil self._sessionAuth = nil self.credentialSession = credentialSession @@ -285,7 +284,7 @@ public final class WalletClient: @unchecked Sendable { self.signedClientFactory = makeSignedClient self._walletId = "" - self._walletAddress = "" + self._activeWallet = nil self._sessionExpiresAt = nil self._sessionAuth = nil self.credentialSession = credentialSession @@ -404,10 +403,10 @@ public final class WalletClient: @unchecked Sendable { } func requireActiveWalletAddress() throws -> String { - guard let walletAddress else { + guard let activeWallet else { throw OMSWalletError.sessionMissing() } - return walletAddress + return activeWallet.address } func walletAddressIfNeeded(for selectFeeOption: FeeOptionSelector?) throws -> String? { @@ -423,13 +422,12 @@ public final class WalletClient: @unchecked Sendable { } } - /// Persists the given wallet address and signer metadata to the keychain + /// Persists the given wallet and signer metadata to the keychain /// so the session can be restored on a later launch. /// - /// - Parameter address: The on-chain address returned by `createWallet` or `useWallet`. + /// - Parameter wallet: The wallet returned by `createWallet`, `useWallet`, or wallet import. func createSequenceWallet( - walletAddress: String, - walletId: String, + wallet: Wallet, sessionMetadata: SessionMetadata, requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil @@ -438,18 +436,19 @@ public final class WalletClient: @unchecked Sendable { try self.withSessionLock { try self.requireCurrentSessionRevisionLocked(requiredSessionRevision) try self.credentialSession.persist( - walletId: walletId, - walletAddress: walletAddress, + wallet: wallet, expiresAt: sessionMetadata.expiresAt, auth: sessionMetadata.auth ) self._latestSessionExpiredEvent = nil - self._walletAddress = walletAddress - self._walletId = walletId + self._activeWallet = wallet + self._walletId = wallet.id self._sessionExpiresAt = sessionMetadata.expiresAt self._sessionAuth = sessionMetadata.auth } - self.scheduleSessionExpiry(self.session) + if let snapshot = self.currentSessionSnapshot() { + self.scheduleSessionExpiry(snapshot) + } } if let oidcRedirectAuthOwnership { try withOIDCRedirectAuthOwnership(oidcRedirectAuthOwnership, persist) diff --git a/Sources/OMSWallet/Clients/WalletImports.swift b/Sources/OMSWallet/Clients/WalletImports.swift index b0a8675..9f892e6 100644 --- a/Sources/OMSWallet/Clients/WalletImports.swift +++ b/Sources/OMSWallet/Clients/WalletImports.swift @@ -23,7 +23,7 @@ private enum WalletImportActivationContext { @available(macOS 12.0, iOS 15.0, *) extension WalletClient { - /// Imports and activates an Ethereum or Solana private key. + /// Imports and activates an Ethereum, Solana, or Tron private key. /// /// The plaintext key is sealed locally to a recipient key from an attested WaaS enclave and is /// never sent over the ordinary WaaS transport. @@ -193,15 +193,14 @@ extension WalletClient { context: WalletImportActivationContext ) throws -> WalletSelectionResult { try createSequenceWallet( - walletAddress: wallet.address, - walletId: wallet.id, + wallet: wallet, sessionMetadata: context.metadata, requiredSessionRevision: context.revision ) if case .pending = context { activePendingWalletSelection = nil } - return WalletSelectionResult(walletAddress: wallet.address, wallet: wallet) + return WalletSelectionResult(wallet: wallet) } private func requireWalletImportClient() throws -> WaasClient { diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 779dba9..23e8748 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -114,6 +114,62 @@ extension WalletClient { } } + public func signTronMessage(message: String) async throws -> String { + try await runOMSWalletOperation(.walletSignTronMessage) { + try requireActiveTronWallet() + let walletId = try requireActiveWalletId() + try requireActiveCredential() + return try await signedClient.signMessage( + SignMessageRequest(network: "", walletId: walletId, message: message) + ).signature + } + } + + public func signTronTypedData(typedData: JSONValue) async throws -> String { + try await runOMSWalletOperation(.walletSignTronTypedData) { + try requireActiveTronWallet() + let walletId = try requireActiveWalletId() + try requireActiveCredential() + return try await signedClient.signTypedData( + SignTypedDataRequest(network: "", walletId: walletId, typedData: typedData.waasValue) + ).signature + } + } + + public func isValidTronMessageSignature( + walletAddress: String, + message: String, + signature: String + ) async throws -> Bool { + try await runOMSWalletOperation(.walletIsValidTronMessageSignature) { + try await publicClient.isValidMessageSignature( + IsValidMessageSignatureRequest( + networkFamily: .tron, + walletAddress: walletAddress, + message: message, + signature: signature + ) + ).isValid + } + } + + public func isValidTronTypedDataSignature( + walletAddress: String, + typedData: JSONValue, + signature: String + ) async throws -> Bool { + try await runOMSWalletOperation(.walletIsValidTronTypedDataSignature) { + try await publicClient.isValidTypedDataSignature( + IsValidTypedDataSignatureRequest( + networkFamily: .tron, + walletAddress: walletAddress, + typedData: typedData.waasValue, + signature: signature + ) + ).isValid + } + } + public func sendTransaction( network: Network, to: String, @@ -205,6 +261,79 @@ extension WalletClient { } } + public func sendTronTransaction( + network: TronNetwork, + to: String, + value: String = "0", + data: String? = nil, + selectFeeOption: FeeOptionSelector? = nil, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() + ) async throws -> SendTransactionResponse { + try await runOMSWalletOperation(.walletSendTronTransaction) { + try requireActiveTronWallet() + let walletId = try requireActiveWalletId() + try requireActiveCredential() + let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) + // `data` is forwarded as given: nil omits the field (a TRX transfer), while any value, + // even "0x", makes this a contract call. + let prepared = try await signedClient.prepareTronTransaction( + PrepareTronTransactionRequest( + network: network.rawValue, + walletId: walletId, + to: to, + value: value, + data: data, + mode: TransactionMode.native.waasValue + ) + ) + return try await execute( + feeBalanceNetwork: .tron(network), + prepareResponse: prepared, + feeOptionSelector: selectFeeOption, + waitForStatus: waitForStatus, + statusPolling: statusPolling, + walletAddress: walletAddress + ) + } + } + + public func callTronContract( + network: TronNetwork, + contract: String, + method: String, + args: [AbiArg]? = nil, + selectFeeOption: FeeOptionSelector? = nil, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() + ) async throws -> SendTransactionResponse { + try await runOMSWalletOperation(.walletCallTronContract) { + try requireActiveTronWallet() + let walletId = try requireActiveWalletId() + try requireActiveCredential() + try requireContractMethodName(method) + let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) + let prepared = try await signedClient.prepareTronContractCall( + PrepareTronContractCallRequest( + network: network.rawValue, + walletId: walletId, + contract: contract, + method: method, + args: args?.map { $0.waasValue }, + mode: TransactionMode.native.waasValue + ) + ) + return try await execute( + feeBalanceNetwork: .tron(network), + prepareResponse: prepared, + feeOptionSelector: selectFeeOption, + waitForStatus: waitForStatus, + statusPolling: statusPolling, + walletAddress: walletAddress + ) + } + } + private func sendTransaction( network: Network, request: SendTransactionRequest, @@ -249,6 +378,7 @@ extension WalletClient { try requireActiveEthereumWallet() let walletId = try requireActiveWalletId() try requireActiveCredential() + try requireContractMethodName(method) let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) let prepareResponse = try await signedClient.prepareEthereumContractCall( PrepareEthereumContractCallRequest( @@ -402,6 +532,12 @@ extension WalletClient { walletAddress: walletAddress, feeOptions: feeOptions ) + case .tron(let network): + options = await enrichTronFeeOptionsWithBalances( + network: network, + walletAddress: walletAddress, + feeOptions: feeOptions + ) } } else { options = feeOptions.enumerated().map { index, feeOption in @@ -481,7 +617,7 @@ extension WalletClient { ) async -> [FeeOptionWithBalance] { let mintAddresses = feeOptions .filter { !$0.token.isNativeToken } - .compactMap { normalizedSolanaAddress($0.token.contractAddress) } + .compactMap { normalizedBase58Address($0.token.contractAddress) } .reduce(into: [String]()) { addresses, address in if !addresses.contains(address) { addresses.append(address) @@ -513,7 +649,58 @@ extension WalletClient { return feeOptions.enumerated().map { index, feeOption in let balance = feeOption.token.isNativeToken ? nativeBalance - : normalizedSolanaAddress(feeOption.token.contractAddress).flatMap { balancesByMint[$0] } + : normalizedBase58Address(feeOption.token.contractAddress).flatMap { balancesByMint[$0] } + let decimals = balance?.decimals ?? feeOption.token.decimals.map(Int.init) + return FeeOptionWithBalance( + feeOption: feeOption, + selection: FeeOptionSelection(feeOption: feeOption, index: UInt32(index)), + available: formatTokenAmount(balance?.rawBalance, decimals: decimals), + availableRaw: balance?.rawBalance, + decimals: decimals + ) + } + } + + private func enrichTronFeeOptionsWithBalances( + network: TronNetwork, + walletAddress: String, + feeOptions: [FeeOption] + ) async -> [FeeOptionWithBalance] { + let contractAddresses = feeOptions + .filter { !$0.token.isNativeToken } + .compactMap { normalizedBase58Address($0.token.contractAddress) } + .reduce(into: [String]()) { addresses, address in + if !addresses.contains(address) { + addresses.append(address) + } + } + let includesNative = feeOptions.contains { $0.token.isNativeToken } + let balances = try? await indexerClient.getTronBalances( + GetTronBalancesParams( + walletAddress: walletAddress, + networks: [network], + includeMetadata: false, + omitNativeBalances: !includesNative, + contractAddresses: contractAddresses + ) + ) + let nativeBalance = balances?.balances.first { balance in + if case .native(let value) = balance { + return value.network == network + } + return false + } + var balancesByContract: [String: TronBalance] = [:] + for balance in balances?.balances ?? [] { + if case .fungibleToken(let value) = balance, value.network == network { + balancesByContract[value.contractAddress] = balance + } + } + + return feeOptions.enumerated().map { index, feeOption in + let balance = feeOption.token.isNativeToken + ? nativeBalance + : normalizedBase58Address(feeOption.token.contractAddress).flatMap { balancesByContract[$0] } let decimals = balance?.decimals ?? feeOption.token.decimals.map(Int.init) return FeeOptionWithBalance( feeOption: feeOption, @@ -621,34 +808,59 @@ extension WalletClient { } private func requireActiveEthereumWallet() throws { - guard let walletAddress else { - throw OMSWalletError.sessionMissing() - } - guard Self.isEthereumAddress(walletAddress) else { - throw OMSWalletError( - code: .validationError, - message: "An active Ethereum wallet is required" - ) - } + try requireActiveWalletType(.ethereum) } private func requireActiveSolanaWallet() throws { - guard let walletAddress else { + try requireActiveWalletType(.solana) + } + + private func requireActiveTronWallet() throws { + try requireActiveWalletType(.tron) + } + + /// Checks the stored wallet type, not the address shape. + func requireActiveWalletType(_ type: WalletType) throws { + guard let activeWallet else { throw OMSWalletError.sessionMissing() } - guard !Self.isEthereumAddress(walletAddress) else { + guard activeWallet.type == type else { throw OMSWalletError( code: .validationError, - message: "An active Solana wallet is required" + message: "An active \(type.displayName) wallet is required" ) } } +} + +private extension WalletType { + var displayName: String { + switch self { + case .ethereum: "Ethereum" + case .solana: "Solana" + case .tron: "Tron" + case .unknown(let value): value + } + } +} - static func isEthereumAddress(_ value: String) -> Bool { - value.hasPrefix("0x") +// Mirrors the wallet service: it builds the signature from the argument types and accepts only a +// bare function name. +private func requireContractMethodName(_ method: String) throws { + let isBareName = method.utf8.first.map { $0 == 95 || isASCIILetter($0) } == true + && method.utf8.allSatisfy { $0 == 95 || isASCIILetter($0) || ($0 >= 48 && $0 <= 57) } + guard isBareName else { + throw OMSWalletError( + code: .validationError, + message: "method must be a function name such as 'transfer', not a signature; got '\(method)'" + ) } } +private func isASCIILetter(_ byte: UInt8) -> Bool { + (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) +} + @available(macOS 12.0, iOS 15.0, *) private extension Array where Element == FeeOption { func defaultSelection() -> FeeOptionSelection? { @@ -671,6 +883,23 @@ private extension FeeToken { private enum FeeBalanceNetwork { case ethereum(Network) case solana(SolanaNetwork) + case tron(TronNetwork) +} + +private extension TronBalance { + var rawBalance: String { + switch self { + case .native(let balance): balance.balance + case .fungibleToken(let balance): balance.balance + } + } + + var decimals: Int { + switch self { + case .native(let balance): balance.decimals + case .fungibleToken(let balance): balance.decimals + } + } } private extension SolanaBalance { @@ -697,7 +926,7 @@ private func normalizedAddress(_ address: String?) -> String? { return trimmed.lowercased() } -private func normalizedSolanaAddress(_ address: String?) -> String? { +private func normalizedBase58Address(_ address: String?) -> String? { guard let trimmed = address?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil diff --git a/Sources/OMSWallet/Clients/WalletSessions.swift b/Sources/OMSWallet/Clients/WalletSessions.swift index ee50dec..30add0f 100644 --- a/Sources/OMSWallet/Clients/WalletSessions.swift +++ b/Sources/OMSWallet/Clients/WalletSessions.swift @@ -2,10 +2,11 @@ import Foundation @available(macOS 12.0, iOS 15.0, *) extension WalletClient { - /// Snapshot of the current durable wallet-session state. - public var session: OMSWalletSessionState { + /// Expiry and auth metadata for the active wallet session. Non-`nil` exactly when + /// `activeWallet` is. + public var session: OMSWalletSession? { withSessionLock { - currentSessionLocked() + currentSessionLocked()?.session } } @@ -14,9 +15,14 @@ extension WalletClient { return } - let storedSession = OMSWalletSessionState( - walletAddress: storedWallet.walletAddress, - expiresAtString: storedWallet.expiresAt, + guard let storedExpiresAt = OMSWalletSession.parseDate(storedWallet.expiresAt) else { + // An unreadable expiry is treated as expired; restore() discards the stored session. + _ = credentialSession.restore() + return + } + let storedSession = WalletSessionSnapshot( + wallet: storedWallet.wallet, + expiresAt: storedExpiresAt, auth: storedWallet.auth ) guard !isSessionExpired(storedSession) else { @@ -27,21 +33,20 @@ extension WalletClient { return } - walletId = restoredWallet.walletId - walletAddress = restoredWallet.walletAddress + walletId = restoredWallet.wallet.id + activeWallet = restoredWallet.wallet sessionExpiresAt = restoredWallet.expiresAt sessionAuth = restoredWallet.auth - scheduleSessionExpiry(session) + if let snapshot = currentSessionSnapshot() { + scheduleSessionExpiry(snapshot) + } } - func isSessionExpired(_ session: OMSWalletSessionState) -> Bool { - guard let expiresAt = session.expiresAt else { - return false - } - return currentDate() >= expiresAt + func isSessionExpired(_ session: WalletSessionSnapshot) -> Bool { + currentDate() >= session.expiresAt } - private func expireStoredSession(_ session: OMSWalletSessionState) { + private func expireStoredSession(_ session: WalletSessionSnapshot) { deliverSessionExpiredNotification( withSessionLock { try? credentialSession.clearSignerKeepingCredentials() @@ -51,7 +56,7 @@ extension WalletClient { ) } - func expireSession(_ session: OMSWalletSessionState) { + func expireSession(_ session: WalletSessionSnapshot) { deliverSessionExpiredNotification( withSessionLock { clearActiveSessionForExpiryLocked() @@ -60,22 +65,30 @@ extension WalletClient { ) } - private func currentSessionLocked() -> OMSWalletSessionState { - guard let walletAddress else { - return OMSWalletSessionState(walletAddress: nil) + func currentSessionSnapshot() -> WalletSessionSnapshot? { + withSessionLock { + currentSessionLocked() + } + } + + private func currentSessionLocked() -> WalletSessionSnapshot? { + guard let activeWallet, + let expiresAt = OMSWalletSession.parseDate(sessionExpiresAt), + let sessionAuth else { + return nil } - return OMSWalletSessionState( - walletAddress: walletAddress, - expiresAtString: sessionExpiresAt, + return WalletSessionSnapshot( + wallet: activeWallet, + expiresAt: expiresAt, auth: sessionAuth ) } func expireCurrentSessionIfNeeded() -> SessionExpiredNotification? { withSessionLock { - let currentSession = currentSessionLocked() - guard isSessionExpired(currentSession) else { + guard let currentSession = currentSessionLocked(), + isSessionExpired(currentSession) else { return nil } clearActiveSessionForExpiryLocked() @@ -89,7 +102,7 @@ extension WalletClient { sessionExpiryTask = nil activePendingWalletSelection = nil try? credentialSession.clearSignerKeepingCredentials() - walletAddress = nil + activeWallet = nil walletId = "" verifier = "" challenge = "" @@ -99,11 +112,12 @@ extension WalletClient { signedClient = signedClientFactory(credentialSession.signer) } - private func makeSessionExpiredNotificationLocked(_ session: OMSWalletSessionState) -> SessionExpiredNotification? { - guard let expiredAt = session.expiresAt else { - return nil - } - let event = OMSWalletSessionExpiredEvent(session: session, expiredAt: expiredAt) + private func makeSessionExpiredNotificationLocked(_ session: WalletSessionSnapshot) -> SessionExpiredNotification { + let event = OMSWalletSessionExpiredEvent( + wallet: session.wallet, + session: session.session, + expiredAt: session.expiresAt + ) latestSessionExpiredEvent = event return (sessionExpiredObserversLocked(), event) } @@ -123,18 +137,8 @@ extension WalletClient { } } - func scheduleSessionExpiry(_ session: OMSWalletSessionState) { - guard let expiresAt = session.expiresAt else { - withSessionLock { - guard isCurrentSessionSnapshotLocked(session) else { - return - } - sessionExpiryTask?.cancel() - sessionExpiryTask = nil - } - return - } - let delay = max(0, expiresAt.timeIntervalSince(currentDate())) + func scheduleSessionExpiry(_ session: WalletSessionSnapshot) { + let delay = max(0, session.expiresAt.timeIntervalSince(currentDate())) guard delay > 0 else { expireSessionFromTimer(session) return @@ -164,10 +168,10 @@ extension WalletClient { } } - private func expireSessionFromTimer(_ session: OMSWalletSessionState) { + private func expireSessionFromTimer(_ session: WalletSessionSnapshot) { let transition = withSessionLock { () -> ( notification: SessionExpiredNotification?, - reschedule: OMSWalletSessionState? + reschedule: WalletSessionSnapshot? ) in guard isCurrentSessionSnapshotLocked(session) else { return (nil, nil) @@ -184,24 +188,22 @@ extension WalletClient { deliverSessionExpiredNotification(transition.notification) } - private func isCurrentSessionSnapshotLocked(_ session: OMSWalletSessionState) -> Bool { - guard let sessionWalletAddress = session.walletAddress else { + private func isCurrentSessionSnapshotLocked(_ session: WalletSessionSnapshot) -> Bool { + guard session.wallet != nil else { return false } - return walletAddress == sessionWalletAddress - && OMSWalletSessionState.parseDate(sessionExpiresAt) == session.expiresAt - && sessionAuth == session.auth + return currentSessionLocked() == session } func reauthenticationSessionEmail() -> String? { withSessionLock { - currentSessionLocked().auth?.email ?? latestSessionExpiredEvent?.session.auth?.email + currentSessionLocked()?.auth.email ?? latestSessionExpiredEvent?.session.auth.email } } func currentSessionMetadata() throws -> SessionMetadata { try withSessionLock { - guard let sessionAuth else { + guard let sessionAuth, let sessionExpiresAt else { throw OMSWalletError.sessionMissing() } return SessionMetadata( @@ -239,7 +241,7 @@ extension WalletClient { self.sessionExpiryTask = nil self.activePendingWalletSelection = nil try self.credentialSession.clear() - self.walletAddress = nil + self.activeWallet = nil self.walletId = "" self.verifier = "" self.challenge = "" @@ -316,9 +318,7 @@ extension WalletClient { try await runOMSWalletOperation(.walletAuthorizeRemoteAccess) { let walletId = try requireActiveWalletId() try requireActiveCredential() - guard let walletAddress, Self.isEthereumAddress(walletAddress) else { - throw OMSWalletError(code: .validationError, message: "An active Ethereum wallet is required") - } + try requireActiveWalletType(.ethereum) try validateSmartSessionGrants(grants) let response = try await signedClient.authorizeRemoteAccess( AuthorizeRemoteAccessRequest( @@ -517,14 +517,6 @@ private func isCanonicalUnsignedDecimal(_ value: String) -> Bool { return value == "0" || !value.hasPrefix("0") } -private func isEthereumAddressValue(_ value: String) -> Bool { - value.count == 42 - && value.hasPrefix("0x") - && value.dropFirst(2).allSatisfy { character in - character.isASCII && character.isHexDigit - } -} - @available(macOS 12.0, iOS 15.0, *) public struct ListAccessPages: AsyncSequence { public typealias Element = AccessGrantPage diff --git a/Sources/OMSWallet/Generated/WaasGeneratedAliases.swift b/Sources/OMSWallet/Generated/WaasGeneratedAliases.swift index 422477b..6657154 100644 --- a/Sources/OMSWallet/Generated/WaasGeneratedAliases.swift +++ b/Sources/OMSWallet/Generated/WaasGeneratedAliases.swift @@ -43,6 +43,8 @@ internal typealias ListWalletsResponse = WaasGenerated.ListWalletsResponse internal typealias PrepareEthereumContractCallRequest = WaasGenerated.PrepareEthereumContractCallRequest internal typealias PrepareEthereumTransactionRequest = WaasGenerated.PrepareEthereumTransactionRequest internal typealias PrepareSolanaTransferRequest = WaasGenerated.PrepareSolanaTransferRequest +internal typealias PrepareTronContractCallRequest = WaasGenerated.PrepareTronContractCallRequest +internal typealias PrepareTronTransactionRequest = WaasGenerated.PrepareTronTransactionRequest internal typealias PrepareResponse = WaasGenerated.PrepareResponse internal typealias SolanaRecipient = WaasGenerated.SolanaRecipient internal typealias RevokeAccessRequest = WaasGenerated.RevokeAccessRequest diff --git a/Sources/OMSWallet/Generated/waas.gen.swift b/Sources/OMSWallet/Generated/waas.gen.swift index dd75625..e60e5f2 100644 --- a/Sources/OMSWallet/Generated/waas.gen.swift +++ b/Sources/OMSWallet/Generated/waas.gen.swift @@ -2,18 +2,18 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif -// waas v1-26.9.9-42835a0a 2adcb9fa810bf56d76cec4c246c328e3ccad75de +// waas v1-26.9.22-f21ed9d2 d92aac00f53699122564bcdbb03aca8afa5c3371 // // Code generated by webrpc-gen@v0.37.2 with github.com/webrpc/gen-swift@v0.1.4 generator. DO NOT EDIT. // // webrpc-gen -schema=schema/waas.ridl -target=github.com/webrpc/gen-swift@v0.1.4 -client -visibility=internal -namespace=WaasGenerated -out=./clients/waas.gen.swift internal enum WaasGenerated { internal static let WEBRPC_VERSION = "v1" -internal static let WEBRPC_SCHEMA_VERSION = "v1-26.9.9-42835a0a" -internal static let WEBRPC_SCHEMA_HASH = "2adcb9fa810bf56d76cec4c246c328e3ccad75de" +internal static let WEBRPC_SCHEMA_VERSION = "v1-26.9.22-f21ed9d2" +internal static let WEBRPC_SCHEMA_HASH = "d92aac00f53699122564bcdbb03aca8afa5c3371" internal static let WEBRPC_HEADER = "Webrpc" -internal static let WEBRPC_HEADER_VALUE = "webrpc@v0.37.2;gen-swift@v0.1.4;waas@v1-26.9.9-42835a0a" +internal static let WEBRPC_HEADER_VALUE = "webrpc@v0.37.2;gen-swift@v0.1.4;waas@v1-26.9.22-f21ed9d2" internal struct WebRPCGeneratedVersions: Sendable, Equatable { internal let webrpcGenVersion: String @@ -684,6 +684,7 @@ private static func webRPCBadResponseError( internal enum WalletType: Codable, Hashable, Sendable, WebRPCEnumKey { case ethereum case solana + case tron case unknown(String) internal var wireValue: String { @@ -692,6 +693,8 @@ internal enum WalletType: Codable, Hashable, Sendable, WebRPCEnumKey { return "ethereum" case .solana: return "solana" + case .tron: + return "tron" case .unknown(let value): return value } @@ -703,6 +706,8 @@ internal enum WalletType: Codable, Hashable, Sendable, WebRPCEnumKey { self = .ethereum case "solana": self = .solana + case "tron": + self = .tron default: self = .unknown(wireValue) } @@ -724,6 +729,7 @@ internal enum WalletType: Codable, Hashable, Sendable, WebRPCEnumKey { internal enum NetworkFamily: Codable, Hashable, Sendable, WebRPCEnumKey { case evm case solana + case tron case unknown(String) internal var wireValue: String { @@ -732,6 +738,8 @@ internal enum NetworkFamily: Codable, Hashable, Sendable, WebRPCEnumKey { return "evm" case .solana: return "solana" + case .tron: + return "tron" case .unknown(let value): return value } @@ -743,6 +751,8 @@ internal enum NetworkFamily: Codable, Hashable, Sendable, WebRPCEnumKey { self = .evm case "solana": self = .solana + case "tron": + self = .tron default: self = .unknown(wireValue) } @@ -2277,6 +2287,64 @@ internal struct PrepareSolanaTransferRequest: Codable, Sendable { +internal struct PrepareTronTransactionRequest: Codable, Sendable { + internal let network: String + internal let walletId: String + internal let to: String + internal let value: String + internal let data: String? + internal let mode: TransactionMode + + internal init(network: String, walletId: String, to: String, value: String, data: String? = nil, mode: TransactionMode + ) { + self.network = network + self.walletId = walletId + self.to = to + self.value = value + self.data = data + self.mode = mode + } + enum CodingKeys: String, CodingKey { + case network = "network" + case walletId = "walletId" + case to = "to" + case value = "value" + case data = "data" + case mode = "mode" + } +} + + + +internal struct PrepareTronContractCallRequest: Codable, Sendable { + internal let network: String + internal let walletId: String + internal let contract: String + internal let method: String + internal let args: [AbiArg]? + internal let mode: TransactionMode + + internal init(network: String, walletId: String, contract: String, method: String, args: [AbiArg]? = nil, mode: TransactionMode + ) { + self.network = network + self.walletId = walletId + self.contract = contract + self.method = method + self.args = args + self.mode = mode + } + enum CodingKeys: String, CodingKey { + case network = "network" + case walletId = "walletId" + case contract = "contract" + case method = "method" + case args = "args" + case mode = "mode" + } +} + + + internal struct ExecuteRequest: Codable, Sendable { internal let txnId: String internal let feeOption: FeeOptionSelection? @@ -2578,14 +2646,16 @@ internal struct IsValidMessageSignatureResponse: Codable, Sendable { internal struct IsValidTypedDataSignatureRequest: Codable, Sendable { internal let network: String? + internal let networkFamily: NetworkFamily? internal let walletAddress: String? internal let walletId: String? internal let typedData: WebRPCJSONValue internal let signature: String - internal init(network: String? = nil, walletAddress: String? = nil, walletId: String? = nil, typedData: WebRPCJSONValue, signature: String + internal init(network: String? = nil, networkFamily: NetworkFamily? = nil, walletAddress: String? = nil, walletId: String? = nil, typedData: WebRPCJSONValue, signature: String ) { self.network = network + self.networkFamily = networkFamily self.walletAddress = walletAddress self.walletId = walletId self.typedData = typedData @@ -2593,6 +2663,7 @@ internal struct IsValidTypedDataSignatureRequest: Codable, Sendable { } enum CodingKeys: String, CodingKey { case network = "network" + case networkFamily = "networkFamily" case walletAddress = "walletAddress" case walletId = "walletId" case typedData = "typedData" @@ -3048,6 +3119,42 @@ internal enum WaasAPI { } } + internal enum PrepareTronTransaction { + internal static let path = "/PrepareTronTransaction" + internal static let urlPath = "/v1/Waas/PrepareTronTransaction" + internal static func encodeRequest( + _ request: PrepareTronTransactionRequest, + encoder: JSONEncoder = WebRPCJSON.makeEncoder() + ) throws -> Data { + try encoder.encode(request) + } + + internal static func decodeResponse( + _ data: Data, + decoder: JSONDecoder = WebRPCJSON.makeDecoder() + ) throws -> PrepareResponse { + try decoder.decode(PrepareResponse.self, from: data) + } + } + + internal enum PrepareTronContractCall { + internal static let path = "/PrepareTronContractCall" + internal static let urlPath = "/v1/Waas/PrepareTronContractCall" + internal static func encodeRequest( + _ request: PrepareTronContractCallRequest, + encoder: JSONEncoder = WebRPCJSON.makeEncoder() + ) throws -> Data { + try encoder.encode(request) + } + + internal static func decodeResponse( + _ data: Data, + decoder: JSONDecoder = WebRPCJSON.makeDecoder() + ) throws -> PrepareResponse { + try decoder.decode(PrepareResponse.self, from: data) + } + } + internal enum Execute { internal static let path = "/Execute" internal static let urlPath = "/v1/Waas/Execute" @@ -3438,6 +3545,38 @@ internal struct WaasClient: Sendable { ) } + internal func prepareTronTransaction(_ request:PrepareTronTransactionRequest) async throws -> PrepareResponse { + let body = try WaasAPI.PrepareTronTransaction.encodeRequest(request) + let requestHeaders = makeWebRPCHeaders( + headers(), + includeWebRPCHeader: true + ) + return try await executeWebRPC( + baseURL: baseURL, + urlPath: WaasAPI.PrepareTronTransaction.urlPath, + body: body, + transport: transport, + headers: requestHeaders, + decodeSuccess: WaasAPI.PrepareTronTransaction.decodeResponse, + ) + } + + internal func prepareTronContractCall(_ request:PrepareTronContractCallRequest) async throws -> PrepareResponse { + let body = try WaasAPI.PrepareTronContractCall.encodeRequest(request) + let requestHeaders = makeWebRPCHeaders( + headers(), + includeWebRPCHeader: true + ) + return try await executeWebRPC( + baseURL: baseURL, + urlPath: WaasAPI.PrepareTronContractCall.urlPath, + body: body, + transport: transport, + headers: requestHeaders, + decodeSuccess: WaasAPI.PrepareTronContractCall.decodeResponse, + ) + } + internal func execute(_ request:ExecuteRequest) async throws -> ExecuteResponse { let body = try WaasAPI.Execute.encodeRequest(request) let requestHeaders = makeWebRPCHeaders( @@ -3704,4 +3843,4 @@ internal struct WaasPublicClient: Sendable { -} +} \ No newline at end of file diff --git a/Sources/OMSWallet/Models/Auth/CompleteAuthResult.swift b/Sources/OMSWallet/Models/Auth/CompleteAuthResult.swift index 8e86abc..5d10a06 100644 --- a/Sources/OMSWallet/Models/Auth/CompleteAuthResult.swift +++ b/Sources/OMSWallet/Models/Auth/CompleteAuthResult.swift @@ -3,7 +3,6 @@ import Foundation @available(macOS 12.0, iOS 15.0, *) public enum CompleteAuthResult: Sendable { case walletSelected( - walletAddress: String, wallet: Wallet, wallets: [Wallet], credential: WalletCredential @@ -12,25 +11,16 @@ public enum CompleteAuthResult: Sendable { public var credential: WalletCredential { switch self { - case .walletSelected(_, _, _, let credential): + case .walletSelected(_, _, let credential): return credential case .walletSelection(let pendingSelection): return pendingSelection.credential } } - public var walletAddress: String? { - switch self { - case .walletSelected(let walletAddress, _, _, _): - return walletAddress - case .walletSelection: - return nil - } - } - public var wallet: Wallet? { switch self { - case .walletSelected(_, let wallet, _, _): + case .walletSelected(let wallet, _, _): return wallet case .walletSelection: return nil diff --git a/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift b/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift index 5e0ab9b..96877e8 100644 --- a/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift +++ b/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift @@ -2,11 +2,9 @@ import Foundation @available(macOS 12.0, iOS 15.0, *) public struct WalletSelectionResult: Sendable { - public let walletAddress: String public let wallet: Wallet - public init(walletAddress: String, wallet: Wallet) { - self.walletAddress = walletAddress + public init(wallet: Wallet) { self.wallet = wallet } } diff --git a/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift new file mode 100644 index 0000000..7a68675 --- /dev/null +++ b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift @@ -0,0 +1,163 @@ +import Foundation + +public enum TronVerificationStatus: String, Codable, Sendable { + case verified + case unverified + case unknown +} + +public enum TronTokenStandard: String, Codable, Sendable { + case trc20 +} + +public struct TronNativeBalance: Codable, Sendable { + public let network: TronNetwork + /// Base58Check (`T…`) account address. + public let accountAddress: String + public let name: String + public let symbol: String + public let decimals: Int + /// Raw balance in sun (1 TRX = 1,000,000 sun). + public let balance: String + public let formattedBalance: String + public let imageUrl: String? + public let metadataUri: String? + public let verificationStatus: TronVerificationStatus + public let verificationSource: String + public let priceUSD: String? + public let balanceUSD: String? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: TronBalanceCodingKeys.self) + network = try container.decode(TronNetwork.self, forKey: .network) + accountAddress = try container.decode(String.self, forKey: .accountAddress) + name = try container.decode(String.self, forKey: .name) + symbol = try container.decode(String.self, forKey: .symbol) + decimals = try container.decode(Int.self, forKey: .decimals) + balance = try container.decode(String.self, forKey: .balance) + formattedBalance = try container.decode(String.self, forKey: .formattedBalance) + imageUrl = try container.decodeNonEmptyString(forKey: .imageUrl) + metadataUri = try container.decodeNonEmptyString(forKey: .metadataUri) + verificationStatus = try container.decode(TronVerificationStatus.self, forKey: .verificationStatus) + verificationSource = try container.decode(String.self, forKey: .verificationSource) + priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) + balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) + } +} + +public struct TronFungibleTokenBalance: Codable, Sendable { + public let network: TronNetwork + /// Base58Check (`T…`) account address. + public let accountAddress: String + public let tokenStandard: TronTokenStandard + /// Base58Check (`T…`) TRC-20 contract address. + public let contractAddress: String + public let name: String + public let symbol: String + public let decimals: Int + /// Raw balance in the token's base units. + public let balance: String + public let formattedBalance: String + public let imageUrl: String? + public let metadataUri: String? + public let verificationStatus: TronVerificationStatus + public let verificationSource: String + public let priceUSD: String? + public let balanceUSD: String? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: TronBalanceCodingKeys.self) + network = try container.decode(TronNetwork.self, forKey: .network) + accountAddress = try container.decode(String.self, forKey: .accountAddress) + tokenStandard = try container.decode(TronTokenStandard.self, forKey: .tokenStandard) + contractAddress = try container.decode(String.self, forKey: .contractAddress) + name = try container.decode(String.self, forKey: .name) + symbol = try container.decode(String.self, forKey: .symbol) + decimals = try container.decode(Int.self, forKey: .decimals) + balance = try container.decode(String.self, forKey: .balance) + formattedBalance = try container.decode(String.self, forKey: .formattedBalance) + imageUrl = try container.decodeNonEmptyString(forKey: .imageUrl) + metadataUri = try container.decodeNonEmptyString(forKey: .metadataUri) + verificationStatus = try container.decode(TronVerificationStatus.self, forKey: .verificationStatus) + verificationSource = try container.decode(String.self, forKey: .verificationSource) + priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) + balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) + } +} + +private enum TronBalanceCodingKeys: String, CodingKey { + case network, accountAddress, tokenStandard, contractAddress, name, symbol, decimals, balance + case formattedBalance, imageUrl, metadataUri, verificationStatus, verificationSource, priceUSD + case balanceUSD +} + +private extension KeyedDecodingContainer where Key == TronBalanceCodingKeys { + func decodeNonEmptyString(forKey key: Key) throws -> String? { + guard let value = try decodeIfPresent(String.self, forKey: key), !value.isEmpty else { + return nil + } + return value + } +} + +public enum TronBalance: Decodable, Sendable { + case native(TronNativeBalance) + case fungibleToken(TronFungibleTokenBalance) + + private enum CodingKeys: String, CodingKey { case assetType, tokenStandard, contractAddress } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .assetType) { + case "native": + guard try container.decodeIfPresent(String.self, forKey: .tokenStandard) == nil, + try container.decodeIfPresent(String.self, forKey: .contractAddress) == nil else { + throw DecodingError.dataCorruptedError(forKey: .assetType, in: container, debugDescription: "Native balance contains token fields") + } + self = .native(try TronNativeBalance(from: decoder)) + case "fungible-token": + self = .fungibleToken(try TronFungibleTokenBalance(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .assetType, in: container, debugDescription: "Unsupported Tron asset type") + } + } +} + +public struct TronNetworkError: Codable, Sendable { + public let network: TronNetwork + public let reason: String +} + +public struct GetTronBalancesParams: Sendable { + /// Base58Check (`T…`) wallet address. + public let walletAddress: String + public let networks: [TronNetwork] + public let includeMetadata: Bool + public let omitNativeBalances: Bool? + /// Only return these TRC-20 contracts (`T…`). + public let contractAddresses: [String] + /// Exclude these TRC-20 contracts (`T…`). + public let excludedContractAddresses: [String] + + public init( + walletAddress: String, + networks: [TronNetwork] = [.mainnet, .nile], + includeMetadata: Bool = true, + omitNativeBalances: Bool? = nil, + contractAddresses: [String] = [], + excludedContractAddresses: [String] = [] + ) { + self.walletAddress = walletAddress + self.networks = networks + self.includeMetadata = includeMetadata + self.omitNativeBalances = omitNativeBalances + self.contractAddresses = contractAddresses + self.excludedContractAddresses = excludedContractAddresses + } +} + +public struct TronBalancesResult: Sendable { + public let status: Int + public let balances: [TronBalance] + public let errors: [TronNetworkError] +} diff --git a/Sources/OMSWallet/Models/OMSWalletError.swift b/Sources/OMSWallet/Models/OMSWalletError.swift index 9334129..9ba4831 100644 --- a/Sources/OMSWallet/Models/OMSWalletError.swift +++ b/Sources/OMSWallet/Models/OMSWalletError.swift @@ -48,14 +48,21 @@ public enum OMSWalletOperation: String, Sendable { case walletIsValidMessageSignature = "wallet.isValidMessageSignature" case walletIsValidSolanaMessageSignature = "wallet.isValidSolanaMessageSignature" case walletIsValidTypedDataSignature = "wallet.isValidTypedDataSignature" + case walletSignTronMessage = "wallet.signTronMessage" + case walletSignTronTypedData = "wallet.signTronTypedData" + case walletIsValidTronMessageSignature = "wallet.isValidTronMessageSignature" + case walletIsValidTronTypedDataSignature = "wallet.isValidTronTypedDataSignature" case walletSendTransaction = "wallet.sendTransaction" case walletSendSolanaTransfer = "wallet.sendSolanaTransfer" + case walletSendTronTransaction = "wallet.sendTronTransaction" + case walletCallTronContract = "wallet.callTronContract" case walletCallContract = "wallet.callContract" case walletExecute = "wallet.execute" case walletGetTransactionStatus = "wallet.getTransactionStatus" case walletTransactionStatus = "wallet.transactionStatus" case indexerGetBalances = "indexer.getBalances" case indexerGetSolanaBalances = "indexer.getSolanaBalances" + case indexerGetTronBalances = "indexer.getTronBalances" case indexerGetTransactionHistory = "indexer.getTransactionHistory" } diff --git a/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift new file mode 100644 index 0000000..f37ae68 --- /dev/null +++ b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Expiry and auth metadata for the active wallet session. `WalletClient.session` is non-`nil` +/// exactly when `WalletClient.activeWallet` is. +public struct OMSWalletSession: Equatable, Sendable { + /// Expiration time for the active wallet session. + public let expiresAt: Date + + /// Auth metadata for the active wallet session. + public let auth: OMSWalletSessionAuth + + public init(expiresAt: Date, auth: OMSWalletSessionAuth) { + self.expiresAt = expiresAt + self.auth = auth + } + + static func parseDate(_ value: String?) -> Date? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: value) { + return date + } + + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) + } +} + +public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { + /// The wallet that was active when the session expired, or `nil` when the credential expired + /// while a manual wallet selection was still pending. + public let wallet: Wallet? + public let session: OMSWalletSession + public let expiredAt: Date + + public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: Date) { + self.wallet = wallet + self.session = session + self.expiredAt = expiredAt + } +} + +/// A point-in-time view of a session; `wallet` is `nil` while a wallet selection is pending. +struct WalletSessionSnapshot: Equatable { + let wallet: Wallet? + let expiresAt: Date + let auth: OMSWalletSessionAuth + + var session: OMSWalletSession { + OMSWalletSession(expiresAt: expiresAt, auth: auth) + } +} diff --git a/Sources/OMSWallet/Models/Sessions/OMSWalletSessionState.swift b/Sources/OMSWallet/Models/Sessions/OMSWalletSessionState.swift deleted file mode 100644 index e4b2d31..0000000 --- a/Sources/OMSWallet/Models/Sessions/OMSWalletSessionState.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -public struct OMSWalletSessionState: Equatable, Sendable { - /// Address of the selected wallet in a completed session, or `nil` when the SDK is signed out. - public let walletAddress: String? - - /// Expiration time for the current completed wallet session, or `nil` when unavailable. - public let expiresAt: Date? - - /// Auth metadata for the current completed wallet session. - public let auth: OMSWalletSessionAuth? - - public init( - walletAddress: String?, - expiresAt: Date? = nil, - auth: OMSWalletSessionAuth? = nil - ) { - self.walletAddress = walletAddress - self.expiresAt = expiresAt - self.auth = auth - } - - init( - walletAddress: String?, - expiresAtString: String?, - auth: OMSWalletSessionAuth? - ) { - self.init( - walletAddress: walletAddress, - expiresAt: Self.parseDate(expiresAtString), - auth: auth - ) - } - - static func parseDate(_ value: String?) -> Date? { - guard let value else { - return nil - } - - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: value) { - return date - } - - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: value) - } -} - -public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { - public let session: OMSWalletSessionState - public let expiredAt: Date - - public init(session: OMSWalletSessionState, expiredAt: Date) { - self.session = session - self.expiredAt = expiredAt - } -} diff --git a/Sources/OMSWallet/Models/Sessions/SessionMetadata.swift b/Sources/OMSWallet/Models/Sessions/SessionMetadata.swift index de633a6..3459cf8 100644 --- a/Sources/OMSWallet/Models/Sessions/SessionMetadata.swift +++ b/Sources/OMSWallet/Models/Sessions/SessionMetadata.swift @@ -1,4 +1,4 @@ struct SessionMetadata { - let expiresAt: String? + let expiresAt: String let auth: OMSWalletSessionAuth } diff --git a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift index c70f2e3..4b0ab31 100644 --- a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift +++ b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift @@ -1,25 +1,84 @@ +import Foundation -struct StorableCredentials : Codable { - let walletId: String - let walletAddress: String +/// The persisted wallet-session record. Version 1 records (SDK 0.3.x) stored only the wallet ID and +/// address, without the wallet type; they fail to decode and are discarded, so users sign in again. +struct StorableCredentials: Codable { + static let currentVersion = 2 + + let version: Int + let wallet: Wallet let signerCredentialId: String let alg: SigningAlgorithm let expiresAt: String? let auth: OMSWalletSessionAuth init( - walletId: String, - walletAddress: String, + wallet: Wallet, signerCredentialId: String, alg: SigningAlgorithm, expiresAt: String? = nil, auth: OMSWalletSessionAuth ) { - self.walletId = walletId - self.walletAddress = walletAddress + self.version = Self.currentVersion + self.wallet = wallet self.signerCredentialId = signerCredentialId self.alg = alg self.expiresAt = expiresAt self.auth = auth } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(Int.self, forKey: .version) + guard version == Self.currentVersion else { + throw DecodingError.dataCorruptedError( + forKey: .version, + in: container, + debugDescription: "Unsupported stored session version: \(version)" + ) + } + let wallet = try container.decode(Wallet.self, forKey: .wallet) + guard Self.isValidStoredWallet(wallet) else { + throw DecodingError.dataCorruptedError( + forKey: .wallet, + in: container, + debugDescription: "Stored session has an invalid wallet" + ) + } + self.version = version + self.wallet = wallet + self.signerCredentialId = try container.decode(String.self, forKey: .signerCredentialId) + self.alg = try container.decode(SigningAlgorithm.self, forKey: .alg) + self.expiresAt = try container.decodeIfPresent(String.self, forKey: .expiresAt) + self.auth = try container.decode(OMSWalletSessionAuth.self, forKey: .auth) + } + + private static func isValidStoredWallet(_ wallet: Wallet) -> Bool { + guard !wallet.id.isEmpty, !wallet.address.isEmpty else { + return false + } + switch wallet.keyOrigin { + case .enclave, .imported: + break + case .unknown: + return false + } + switch wallet.type { + case .ethereum: + return isEthereumAddressValue(wallet.address) + case .solana, .tron: + return true + case .unknown: + return false + } + } + + private enum CodingKeys: String, CodingKey { + case version + case wallet + case signerCredentialId + case alg + case expiresAt + case auth + } } diff --git a/Sources/OMSWallet/Models/WaasModelMapping.swift b/Sources/OMSWallet/Models/WaasModelMapping.swift index e5cd429..184b806 100644 --- a/Sources/OMSWallet/Models/WaasModelMapping.swift +++ b/Sources/OMSWallet/Models/WaasModelMapping.swift @@ -59,6 +59,8 @@ extension WalletType { return .ethereum case .solana: return .solana + case .tron: + return .tron case .unknown(let value): return .unknown(value) } @@ -72,6 +74,8 @@ extension WaasGenerated.WalletType { return .ethereum case .solana: return .solana + case .tron: + return .tron case .unknown(let value): return .unknown(value) } @@ -85,6 +89,8 @@ extension WaasGenerated.NetworkFamily { return .ethereum case .solana: return .solana + case .tron: + return .tron case .unknown(let value): return .unknown(value) } @@ -163,6 +169,10 @@ extension Wallet { ) ) } + // Checks the shape only; EIP-55 checksum casing is not enforced. + if type == .ethereum && !isEthereumAddressValue(waasValue.address) { + throw OMSWalletError(code: .invalidResponse, message: "Ethereum wallet response has an invalid address") + } self.init( id: waasValue.id, type: type, @@ -370,7 +380,7 @@ private func isCanonicalUnsignedDecimal(_ value: String) -> Bool { && (value == "0" || !value.hasPrefix("0")) } -private func isEthereumAddressValue(_ value: String) -> Bool { +func isEthereumAddressValue(_ value: String) -> Bool { value.count == 42 && value.hasPrefix("0x") && value.dropFirst(2).allSatisfy { $0.isASCII && $0.isHexDigit } diff --git a/Sources/OMSWallet/Models/WalletModels.swift b/Sources/OMSWallet/Models/WalletModels.swift index dc472cc..1df094c 100644 --- a/Sources/OMSWallet/Models/WalletModels.swift +++ b/Sources/OMSWallet/Models/WalletModels.swift @@ -3,6 +3,7 @@ import Foundation public enum WalletType: Codable, Equatable, Hashable, Sendable { case ethereum case solana + case tron case unknown(String) public var wireValue: String { @@ -11,6 +12,8 @@ public enum WalletType: Codable, Equatable, Hashable, Sendable { return "ethereum" case .solana: return "solana" + case .tron: + return "tron" case .unknown(let value): return value } @@ -22,6 +25,8 @@ public enum WalletType: Codable, Equatable, Hashable, Sendable { self = .ethereum case "solana": self = .solana + case "tron": + self = .tron default: self = .unknown(wireValue) } diff --git a/Sources/OMSWallet/Network.swift b/Sources/OMSWallet/Network.swift index 8ff7b10..7dab8fd 100644 --- a/Sources/OMSWallet/Network.swift +++ b/Sources/OMSWallet/Network.swift @@ -12,6 +12,18 @@ public enum SolanaNetworks { public static let mainnet: SolanaNetwork = .mainnet } +public enum TronNetwork: String, CaseIterable, Codable, Sendable, CustomStringConvertible { + case mainnet = "tron:mainnet" + case nile = "tron:nile" + + public var description: String { rawValue } +} + +public enum TronNetworks { + public static let mainnet: TronNetwork = .mainnet + public static let nile: TronNetwork = .nile +} + public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { case mainnet case sepolia diff --git a/Sources/OMSWallet/OMSWalletEnvironment.swift b/Sources/OMSWallet/OMSWalletEnvironment.swift index e3261bb..47b4808 100644 --- a/Sources/OMSWallet/OMSWalletEnvironment.swift +++ b/Sources/OMSWallet/OMSWalletEnvironment.swift @@ -4,15 +4,18 @@ struct OMSWalletEnvironment: Equatable, Sendable { let walletApiUrl: String let indexerGatewayUrl: String let solanaIndexerGatewayUrl: String + let tronIndexerGatewayUrl: String init( walletApiUrl: String, indexerGatewayUrl: String, - solanaIndexerGatewayUrl: String? = nil + solanaIndexerGatewayUrl: String? = nil, + tronIndexerGatewayUrl: String? = nil ) { + let apiUrl = walletApiUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.walletApiUrl = walletApiUrl self.indexerGatewayUrl = indexerGatewayUrl - self.solanaIndexerGatewayUrl = solanaIndexerGatewayUrl - ?? "\(walletApiUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/v1/SolanaIndexerGateway/" + self.solanaIndexerGatewayUrl = solanaIndexerGatewayUrl ?? "\(apiUrl)/v1/SolanaIndexerGateway/" + self.tronIndexerGatewayUrl = tronIndexerGatewayUrl ?? "\(apiUrl)/v1/TronIndexerGateway/" } } diff --git a/Sources/OMSWallet/PublishableKey.swift b/Sources/OMSWallet/PublishableKey.swift index 2c8e602..3968871 100644 --- a/Sources/OMSWallet/PublishableKey.swift +++ b/Sources/OMSWallet/PublishableKey.swift @@ -5,13 +5,15 @@ struct ParsedPublishableKey: Equatable, Sendable { let walletApiUrl: String let indexerGatewayUrl: String let solanaIndexerGatewayUrl: String + let tronIndexerGatewayUrl: String let walletImportTrustedPcr0s: Set func environment() -> OMSWalletEnvironment { OMSWalletEnvironment( walletApiUrl: walletApiUrl, indexerGatewayUrl: indexerGatewayUrl, - solanaIndexerGatewayUrl: solanaIndexerGatewayUrl + solanaIndexerGatewayUrl: solanaIndexerGatewayUrl, + tronIndexerGatewayUrl: tronIndexerGatewayUrl ) } } @@ -84,6 +86,7 @@ func parsePublishableKey(_ publishableKey: String) throws -> ParsedPublishableKe walletApiUrl: route.apiUrl, indexerGatewayUrl: "\(route.apiUrl)/v1/IndexerGateway/", solanaIndexerGatewayUrl: "\(route.apiUrl)/v1/SolanaIndexerGateway/", + tronIndexerGatewayUrl: "\(route.apiUrl)/v1/TronIndexerGateway/", walletImportTrustedPcr0s: route.walletImportTrustedPcr0s ) } diff --git a/Sources/OMSWallet/Signer/WalletCredentialSession.swift b/Sources/OMSWallet/Signer/WalletCredentialSession.swift index ae0528a..3c61eed 100644 --- a/Sources/OMSWallet/Signer/WalletCredentialSession.swift +++ b/Sources/OMSWallet/Signer/WalletCredentialSession.swift @@ -3,8 +3,7 @@ import Foundation @available(macOS 12.0, iOS 15.0, *) final class WalletCredentialSession { struct WalletMetadata { - let walletId: String - let walletAddress: String + let wallet: Wallet let expiresAt: String? let auth: OMSWalletSessionAuth } @@ -40,8 +39,7 @@ final class WalletCredentialSession { } return WalletMetadata( - walletId: credentials.walletId, - walletAddress: credentials.walletAddress, + wallet: credentials.wallet, expiresAt: credentials.expiresAt, auth: credentials.auth ) @@ -75,8 +73,7 @@ final class WalletCredentialSession { if try signerMatchesStoredCredential(candidateSigner, credentials: credentials) { currentSigner = candidateSigner return WalletMetadata( - walletId: credentials.walletId, - walletAddress: credentials.walletAddress, + wallet: credentials.wallet, expiresAt: credentials.expiresAt, auth: credentials.auth ) @@ -94,15 +91,13 @@ final class WalletCredentialSession { } func persist( - walletId: String, - walletAddress: String, + wallet: Wallet, expiresAt: String?, auth: OMSWalletSessionAuth ) throws { try withLock { let credentials = StorableCredentials( - walletId: walletId, - walletAddress: walletAddress, + wallet: wallet, signerCredentialId: try currentSigner.credentialId(), alg: currentSigner.alg, expiresAt: expiresAt, diff --git a/Sources/OMSWallet/WalletImport.swift b/Sources/OMSWallet/WalletImport.swift index 037a620..25d23fc 100644 --- a/Sources/OMSWallet/WalletImport.swift +++ b/Sources/OMSWallet/WalletImport.swift @@ -13,11 +13,17 @@ public enum WalletImportPrivateKey: Sendable { case ethereumBytes(Data) case solana(String) case solanaBytes(Data) + /// A secp256k1 private key as 64 hexadecimal characters, optionally `0x`-prefixed. Tron uses + /// the same private key format as Ethereum. + case tron(String) + /// A 32-byte secp256k1 private key. + case tronBytes(Data) var walletType: WalletType { switch self { case .ethereum, .ethereumBytes: .ethereum case .solana, .solanaBytes: .solana + case .tron, .tronBytes: .tron } } } @@ -58,6 +64,7 @@ extension WalletType { switch self { case .ethereum: .evm case .solana: .solana + case .tron: .tron case .unknown(let value): .unknown(value) } } @@ -75,19 +82,13 @@ enum WalletImportValidation { static func plaintext(_ privateKey: WalletImportPrivateKey) throws -> Data { switch privateKey { case .ethereum(let value): - let trimmed = trimAsciiWhitespace(value) - let hex = trimmed.hasPrefix("0x") ? String(trimmed.dropFirst(2)) : trimmed - guard hex.count == 64, hex.allSatisfy(\.isHexDigit), let bytes = Data(hexString: hex) else { - throw validation("Ethereum privateKey must be 32 bytes or 64 hexadecimal characters") - } - try requireValidEthereumScalar(bytes) - return Data(trimmed.utf8) + return try secp256k1PrivateKeyPlaintext(value, label: "Ethereum") case .ethereumBytes(let bytes): - guard bytes.count == 32 else { - throw validation("Ethereum privateKey must contain exactly 32 bytes") - } - try requireValidEthereumScalar(bytes) - return bytes + return try secp256k1PrivateKeyPlaintext(bytes, label: "Ethereum") + case .tron(let value): + return try secp256k1PrivateKeyPlaintext(value, label: "Tron") + case .tronBytes(let bytes): + return try secp256k1PrivateKeyPlaintext(bytes, label: "Tron") case .solana(let value): let trimmed = trimAsciiWhitespace(value) guard let decoded = decodeBase58(trimmed), decoded.count == 32 || decoded.count == 64 else { @@ -119,10 +120,28 @@ enum WalletImportValidation { return value } - private static func requireValidEthereumScalar(_ value: Data) throws { + private static func secp256k1PrivateKeyPlaintext(_ value: String, label: String) throws -> Data { + let trimmed = trimAsciiWhitespace(value) + let hex = trimmed.hasPrefix("0x") ? String(trimmed.dropFirst(2)) : trimmed + guard hex.count == 64, hex.allSatisfy(\.isHexDigit), let bytes = Data(hexString: hex) else { + throw validation("\(label) privateKey must be 32 bytes or 64 hexadecimal characters") + } + try requireValidSecp256k1Scalar(bytes, label: label) + return Data(trimmed.utf8) + } + + private static func secp256k1PrivateKeyPlaintext(_ bytes: Data, label: String) throws -> Data { + guard bytes.count == 32 else { + throw validation("\(label) privateKey must contain exactly 32 bytes") + } + try requireValidSecp256k1Scalar(bytes, label: label) + return bytes + } + + private static func requireValidSecp256k1Scalar(_ value: Data, label: String) throws { let bytes = [UInt8](value) guard bytes.contains(where: { $0 != 0 }), bytes.lexicographicallyPrecedes(secp256k1Order) else { - throw validation("Ethereum privateKey is outside the valid secp256k1 scalar range") + throw validation("\(label) privateKey is outside the valid secp256k1 scalar range") } } diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift new file mode 100644 index 0000000..4dffdd7 --- /dev/null +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import OMSWallet + +private let testEnvironment = OMSWalletEnvironment( + walletApiUrl: "https://wallet.example.test", + indexerGatewayUrl: "https://indexer.example.test/v1/IndexerGateway/" +) + +private func makeFixture( + storedRecordJSON: String, + projectId: String = "proj_\(UUID().uuidString)", + currentDate: @escaping () -> Date = Date.init +) throws -> MockWalletClientFixture { + let keychain = InMemoryKeychain() + try keychain.set( + storedRecordJSON, + forKey: Constants.credentialsStorageKey(environment: testEnvironment, scope: projectId) + ) + return makeMockWalletClient( + environment: testEnvironment, + projectId: projectId, + keychain: keychain, + currentDate: currentDate + ) +} + +private func storedRecordJSON(version: Int = 2, walletJSON: String) -> String { + """ + {"version":\(version),"wallet":\(walletJSON),"signerCredentialId":"0xmock-credential",\ + "alg":"ecdsa-p256-sha256","expiresAt":"2099-01-01T00:00:00Z",\ + "auth":{"type":"email","email":"user@example.com"}} + """ +} + +@Test func TestActiveWalletAndSessionAreNilWhenSignedOut() throws { + let fixture = makeMockWalletClient() + + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) +} + +@Test func TestCompletedAuthDefinesActiveWalletAndSessionTogether() async throws { + let fixture = makeMockWalletClient() + let wallet = testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111") + try fixture.transport.enqueue(completeAuthResponse(wallets: [wallet]), for: WaasAPI.CompleteAuth.urlPath) + try fixture.transport.enqueue(UseWalletResponse(wallet: wallet), for: WaasAPI.UseWallet.urlPath) + + let result = try await fixture.client.completeEmailAuth(code: "123456") + + let expected = Wallet( + id: "wallet-1", + type: .ethereum, + address: "0x1111111111111111111111111111111111111111", + keyOrigin: .enclave + ) + #expect(result.wallet == expected) + #expect(fixture.client.activeWallet == expected) + #expect(fixture.client.session?.expiresAt == OMSWalletSession.parseDate("2099-01-01T00:00:00Z")) + #expect(fixture.client.session?.auth == .email(OMSWalletEmailSessionAuth(email: "user@example.com"))) + #expect(try fixture.storedCredentials()?.wallet == expected) + + try fixture.client.signOut() + + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) +} + +@Test func TestRestoresStoredTronWalletWithItsType() throws { + let fixture = try makeFixture( + storedRecordJSON: storedRecordJSON( + walletJSON: #"{"id":"wallet-tron","type":"tron","address":"TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL","reference":"main","keyOrigin":"imported"}"# + ) + ) + + #expect( + fixture.client.activeWallet + == Wallet( + id: "wallet-tron", + type: .tron, + address: "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL", + reference: "main", + keyOrigin: .imported + ) + ) + #expect(fixture.client.walletId == "wallet-tron") + #expect(fixture.client.session?.auth.email == "user@example.com") +} + +@Test func TestDiscardsVersionOneStoredSessionsOnLoad() throws { + let projectId = "proj_\(UUID().uuidString)" + let fixture = try makeFixture( + storedRecordJSON: """ + {"walletId":"wallet-1","walletAddress":"0x1111111111111111111111111111111111111111",\ + "signerCredentialId":"0xmock-credential","alg":"ecdsa-p256-sha256",\ + "expiresAt":"2099-01-01T00:00:00Z","auth":{"type":"email","email":"user@example.com"}} + """, + projectId: projectId + ) + + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) + #expect(fixture.client.walletId == "") + #expect( + try fixture.keychain.string( + forKey: Constants.credentialsStorageKey(environment: testEnvironment, scope: projectId) + ) == nil + ) +} + +@Test(arguments: [ + #"{"id":"wallet-1","type":"ethereum","address":"0xdef","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","type":"bitcoin","address":"bc1q","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","type":"tron","address":"TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL","keyOrigin":"derived"}"# +]) +func TestDiscardsStoredSessionsWithInvalidWallets(walletJSON: String) throws { + let fixture = try makeFixture(storedRecordJSON: storedRecordJSON(walletJSON: walletJSON)) + + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) + #expect(try fixture.storedCredentials() == nil) +} + +@Test func TestRejectsEthereumWalletResponsesWithNonHexAddresses() async throws { + let fixture = makeMockWalletClient() + fixture.client.walletId = "wallet-main" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") + fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + try fixture.transport.enqueue( + UseWalletResponse(wallet: testWallet(id: "wallet-bad", address: "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL")), + for: WaasAPI.UseWallet.urlPath + ) + + do { + _ = try await fixture.client.useWallet(walletId: "wallet-bad") + Issue.record("Expected an invalid wallet response") + } catch let error as OMSWalletError { + #expect(error.code == .invalidResponse) + #expect(error.operation == .walletUseWallet) + #expect(error.localizedDescription == "Ethereum wallet response has an invalid address") + } + #expect(fixture.client.activeWallet?.id == "wallet-main") + #expect(try fixture.storedCredentials() == nil) +} + +@Test func TestAcceptsMixedCaseEthereumWalletAddressesWithoutChecksumValidation() throws { + let wallet = try Wallet( + waasValue: WaasWallet( + id: "wallet-1", + networkFamily: .evm, + keyOrigin: .enclave, + address: "0xABCDEFabcdef0000000000000000000000000000" + ) + ) + + #expect(wallet.address == "0xABCDEFabcdef0000000000000000000000000000") +} + +@Test func TestRejectsAuthResponsesWithUnreadableCredentialExpiry() async throws { + let fixture = makeMockWalletClient() + let wallet = testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111") + try fixture.transport.enqueue( + completeAuthResponse( + wallets: [wallet], + credential: WaasCredentialInfo( + credentialId: "0xcredential", + type: .direct, + expiresAt: "soon", + isCaller: true + ) + ), + for: WaasAPI.CompleteAuth.urlPath + ) + + do { + _ = try await fixture.client.completeEmailAuth(code: "123456") + Issue.record("Expected an invalid auth response") + } catch let error as OMSWalletError { + #expect(error.code == .invalidResponse) + #expect(error.operation == .walletCompleteEmailAuth) + } + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) +} + +@Test @MainActor func TestPendingWalletSelectionExpiryNotifiesWithoutWallet() async throws { + nonisolated(unsafe) var now = Date(timeIntervalSince1970: 1_767_225_599) + let fixture = makeMockWalletClient(currentDate: { now }) + let wallet = testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111") + try fixture.transport.enqueue( + completeAuthResponse( + wallets: [wallet], + credential: WaasCredentialInfo( + credentialId: "0xcredential", + type: .direct, + expiresAt: "2026-01-01T00:00:00Z", + isCaller: true + ) + ), + for: WaasAPI.CompleteAuth.urlPath + ) + var expiredEvent: OMSWalletSessionExpiredEvent? + let observation = fixture.client.addSessionExpiredObserver { event in + expiredEvent = event + } + defer { observation.cancel() } + + let result = try await fixture.client.completeEmailAuth(code: "123456", walletSelection: .manual) + guard case .walletSelection(let pendingSelection) = result else { + Issue.record("Expected a pending wallet selection") + return + } + #expect(fixture.client.session == nil) + + now = Date(timeIntervalSince1970: 1_767_225_601) + do { + _ = try await pendingSelection.selectWallet(walletId: "wallet-1") + Issue.record("Expected an expired session") + } catch let error as OMSWalletError { + #expect(error.code == .sessionExpired) + } + for _ in 0..<100 where expiredEvent == nil { + try await Task.sleep(nanoseconds: 10_000_000) + } + + let event = try #require(expiredEvent) + #expect(event.wallet == nil) + #expect(event.session.auth.email == "user@example.com") + #expect(event.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(fixture.transport.requestCount(for: WaasAPI.UseWallet.urlPath) == 0) +} diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index 904153a..9ae39f3 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -84,6 +84,9 @@ import Testing #expect(parsedKey.projectId == "prj_project") #expect(parsedKey.walletApiUrl == apiUrl) #expect(parsedKey.indexerGatewayUrl == "\(apiUrl)/v1/IndexerGateway/") + #expect(parsedKey.solanaIndexerGatewayUrl == "\(apiUrl)/v1/SolanaIndexerGateway/") + #expect(parsedKey.tronIndexerGatewayUrl == "\(apiUrl)/v1/TronIndexerGateway/") + #expect(parsedKey.environment().tronIndexerGatewayUrl == "\(apiUrl)/v1/TronIndexerGateway/") #expect(parsedKey.walletImportTrustedPcr0s == Set(walletImportPcr0s)) let oms = try OMSWallet(publishableKey: publishableKey) @@ -207,6 +210,183 @@ import Testing #expect(result.errors.first?.network == .devnet) } +@Test func TestGetTronBalancesUsesTronGatewayAndDecodesStrictAssets() async throws { + let wallet = "TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H" + let recorder = IndexerRequestRecorder( + responseBody: Data( + #""" + { + "balances": [ + { + "network": "tron:nile", + "accountAddress": "TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H", + "assetType": "fungible-token", + "contractAddress": "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + "tokenStandard": "trc20", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "balance": "999000000", + "formattedBalance": "999", + "imageUrl": null, + "metadataUri": null, + "verificationStatus": "unknown", + "verificationSource": "none", + "priceUSD": null, + "balanceUSD": null + }, + { + "network": "tron:nile", + "accountAddress": "TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H", + "assetType": "native", + "contractAddress": null, + "tokenStandard": null, + "name": "Tron", + "symbol": "TRX", + "decimals": 6, + "balance": "983121000", + "formattedBalance": "983.121", + "imageUrl": "", + "metadataUri": null, + "verificationStatus": "unknown", + "verificationSource": "none", + "priceUSD": "0.27", + "balanceUSD": "265.44" + } + ], + "errors": [{"network": "tron:mainnet", "reason": "RPC unavailable"}], + "coverage": {"network": "tron:nile", "tokenSymbols": ["USDT"], "nativeIncluded": true}, + "coverages": [{"network": "tron:nile", "tokenSymbols": ["USDT"], "nativeIncluded": true}] + } + """#.utf8 + ) + ) + let client = makeRecordingIndexerClient(recorder: recorder) + + let result = try await client.getTronBalances( + GetTronBalancesParams( + walletAddress: wallet, + networks: [.nile], + includeMetadata: false, + omitNativeBalances: false, + contractAddresses: ["TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"], + excludedContractAddresses: ["TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"] + ) + ) + + let request = try #require(recorder.recordedRequest()) + let body = try #require(recorder.recordedBody()) + let payload = try #require(JSONSerialization.jsonObject(with: body) as? NSDictionary) + let expectedPayload: NSDictionary = [ + "networks": ["tron:nile"], + "filter": [ + "accountAddresses": [wallet], + "omitNativeBalances": false, + "contractWhitelist": ["TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"], + "contractBlacklist": ["TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"] + ], + "omitMetadata": true + ] + #expect(request.url?.path == "/v1/TronIndexerGateway/GetTokenBalancesDetails") + #expect(request.value(forHTTPHeaderField: "Api-Key") == "test-key") + #expect( + request.value(forHTTPHeaderField: "Webrpc") + == "webrpc@v0.31.2;gen-swift@v0.1.2;tron-indexer-gateway@v1" + ) + #expect(payload == expectedPayload) + #expect(result.status == 200) + #expect(result.balances.count == 2) + guard case .fungibleToken(let token) = result.balances[0], + case .native(let native) = result.balances[1] else { + Issue.record("Expected a TRC-20 balance followed by a native balance") + return + } + #expect(token.network == .nile) + #expect(token.accountAddress == wallet) + #expect(token.tokenStandard == .trc20) + #expect(token.contractAddress == "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf") + #expect(token.symbol == "USDT") + #expect(token.decimals == 6) + #expect(token.balance == "999000000") + #expect(token.formattedBalance == "999") + #expect(token.imageUrl == nil) + #expect(token.priceUSD == nil) + #expect(token.verificationStatus == .unknown) + #expect(token.verificationSource == "none") + #expect(native.symbol == "TRX") + #expect(native.balance == "983121000") + #expect(native.imageUrl == nil) + #expect(native.metadataUri == nil) + #expect(native.priceUSD == "0.27") + #expect(native.balanceUSD == "265.44") + #expect(result.errors.map(\.network) == [.mainnet]) + #expect(result.errors.map(\.reason) == ["RPC unavailable"]) +} + +@Test func TestGetTronBalancesDefaultsToSupportedNetworks() async throws { + let recorder = IndexerRequestRecorder(responseBody: Data(#"{"balances":[],"errors":[]}"#.utf8)) + let client = makeRecordingIndexerClient(recorder: recorder) + + _ = try await client.getTronBalances( + GetTronBalancesParams(walletAddress: "TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H") + ) + + let body = try #require(recorder.recordedBody()) + let payload = try #require(JSONSerialization.jsonObject(with: body) as? NSDictionary) + let expectedPayload: NSDictionary = [ + "networks": ["tron:mainnet", "tron:nile"], + "filter": ["accountAddresses": ["TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H"]], + "omitMetadata": false + ] + #expect(payload == expectedPayload) +} + +@Test(arguments: [ + #"{"balances":[{"network":"tron:shasta","accountAddress":"T","assetType":"native","name":"Tron","symbol":"TRX","decimals":6,"balance":"0","formattedBalance":"0","verificationStatus":"unknown","verificationSource":"none"}],"errors":[]}"#, + #"{"balances":[],"errors":[{"network":"tron:shasta","reason":"down"}]}"#, + #"{"balances":[{"network":"tron:nile","accountAddress":"T","assetType":"native","contractAddress":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","name":"Tron","symbol":"TRX","decimals":6,"balance":"0","formattedBalance":"0","verificationStatus":"unknown","verificationSource":"none"}],"errors":[]}"#, + #"{"balances":[{"network":"tron:nile","accountAddress":"T","assetType":"fungible-token","tokenStandard":"trc10","contractAddress":"1002000","name":"BTT","symbol":"BTT","decimals":6,"balance":"0","formattedBalance":"0","verificationStatus":"unknown","verificationSource":"none"}],"errors":[]}"#, + #"{"balances":[],"errors":null}"# +]) +func TestGetTronBalancesRejectsInvalidResponses(responseBody: String) async throws { + let recorder = IndexerRequestRecorder(responseBody: Data(responseBody.utf8)) + let client = makeRecordingIndexerClient(recorder: recorder) + + do { + _ = try await client.getTronBalances( + GetTronBalancesParams(walletAddress: "TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H") + ) + Issue.record("Expected an invalid Tron balances response") + } catch let error as OMSWalletError { + #expect(error.code == .invalidResponse) + #expect(error.operation == .indexerGetTronBalances) + #expect(error.status == 200) + } +} + +@Test func TestGetTronBalancesSurfacesGatewayRequestErrors() async throws { + let recorder = IndexerRequestRecorder( + statusCode: 400, + responseBody: Data( + #"{"code":-4,"message":"invalid Tron address: 0x1234","name":"WebrpcBadRequest","status":400}"#.utf8 + ) + ) + let client = makeRecordingIndexerClient(recorder: recorder) + + do { + _ = try await client.getTronBalances(GetTronBalancesParams(walletAddress: "0x1234")) + Issue.record("Expected a Tron gateway HTTP error") + } catch let error as OMSWalletError { + #expect(error.code == .httpError) + #expect(error.operation == .indexerGetTronBalances) + #expect(error.status == 400) + #expect(error.retryable == false) + #expect(error.upstreamError?.service == .indexer) + #expect(error.upstreamError?.status == 400) + #expect(error.upstreamError?.message == "invalid Tron address: 0x1234") + } +} + @Test func TestGetTransactionHistoryEncodesGatewayFiltersAndDecodesTransactions() async throws { let recorder = IndexerRequestRecorder( responseBody: Data( @@ -544,7 +724,8 @@ func makeRecordingIndexerClient(recorder: IndexerRequestRecorder) -> IndexerClie let environment = OMSWalletEnvironment( walletApiUrl: "https://wallet.example.test", indexerGatewayUrl: "https://\(host)/v1/IndexerGateway/", - solanaIndexerGatewayUrl: "https://\(host)/v1/SolanaIndexerGateway/" + solanaIndexerGatewayUrl: "https://\(host)/v1/SolanaIndexerGateway/", + tronIndexerGatewayUrl: "https://\(host)/v1/TronIndexerGateway/" ) return IndexerClient( diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 78ad243..44bb066 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -70,8 +70,8 @@ private func isOidcAuth( #expect(request.verifier == "default-lifetime-verifier") #expect(request.lifetime == 604_800) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) - #expect(fixture.client.session.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) + #expect(fixture.client.session == nil) #expect(fixture.transport.requestCount(for: WaasAPI.UseWallet.urlPath) == 0) #expect(fixture.transport.requestCount(for: WaasAPI.CreateWallet.urlPath) == 0) } @@ -95,7 +95,7 @@ private func isOidcAuth( ) let result = try await fixture.client.completeEmailAuth(code: "123456") - guard case .walletSelected(_, let wallet, let wallets, _) = result else { + guard case .walletSelected(let wallet, let wallets, _) = result else { #expect(Bool(false)) return } @@ -110,10 +110,11 @@ private func isOidcAuth( #expect(wallets.map(\.id) == [firstWallet.id, secondWallet.id, otherTypeWallet.id]) #expect(useWalletRequest.walletId == firstWallet.id) #expect(fixture.client.walletId == firstWallet.id) - #expect(fixture.client.walletAddress == firstWallet.address) - #expect(fixture.client.session.walletAddress == firstWallet.address) - #expect(storedCredentials?.walletId == firstWallet.id) - #expect(storedCredentials?.walletAddress == firstWallet.address) + #expect(fixture.client.activeWallet?.address == firstWallet.address) + #expect(fixture.client.session != nil) + #expect(fixture.client.activeWallet?.address == firstWallet.address) + #expect(storedCredentials?.wallet.id == firstWallet.id) + #expect(storedCredentials?.wallet.address == firstWallet.address) #expect(isEmailAuth(storedCredentials?.auth)) } @@ -158,7 +159,8 @@ private func isOidcAuth( let restoredWallets = try await restoredFixture.client.listWallets() #expect(activeWallets.map(\.id) == [wallet.id]) - #expect(restoredFixture.client.session.walletAddress == wallet.address) + #expect(restoredFixture.client.session != nil) + #expect(restoredFixture.client.activeWallet?.address == wallet.address) #expect(restoredWallets.map(\.id) == [wallet.id]) } @@ -190,14 +192,14 @@ private func isOidcAuth( ) #expect(completeAuthRequest.lifetime == 30) - #expect(isEmailAuth(fixture.client.session.auth)) + #expect(isEmailAuth(fixture.client.session?.auth)) } @Test func TestWalletStartEmailAuthRejectsInvalidSessionLifetimeBeforeRequestOrSignOut() async throws { let fixture = makeMockWalletClient() let wallet = testWallet(id: "wallet-existing", address: "0x1111111111111111111111111111111111111111") fixture.client.walletId = wallet.id - fixture.client.walletAddress = wallet.address + fixture.client.activeWallet = activeTestWallet(wallet.address, id: wallet.id) do { try await fixture.client.startEmailAuth(email: "user@example.com", sessionLifetimeSeconds: 0) @@ -212,7 +214,7 @@ private func isOidcAuth( #expect(fixture.transport.requestCount(for: WaasAPI.CommitVerifier.urlPath) == 0) #expect(fixture.client.walletId == wallet.id) - #expect(fixture.client.walletAddress == wallet.address) + #expect(fixture.client.activeWallet?.address == wallet.address) } @Test func TestWalletStartingEmailAuthAgainReplacesPendingSessionLifetime() async throws { @@ -332,15 +334,15 @@ private func isOidcAuth( _ = try await waitForSessionExpiredEvent { secondExpiredEvent } let storedCredentials = try fixture.storedCredentials() - #expect(expiredEvent?.session.walletAddress == wallet.address) + #expect(expiredEvent?.wallet?.address == wallet.address) #expect(expiredEvent?.session.expiresAt == Date(timeIntervalSince1970: 1_767_225_600)) #expect(isEmailAuth(expiredEvent?.session.auth)) #expect(expiredEvent?.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) - #expect(secondExpiredEvent?.session.walletAddress == wallet.address) + #expect(secondExpiredEvent?.wallet?.address == wallet.address) #expect(canceledExpiredEvent == nil) - #expect(fixture.client.session == OMSWalletSessionState(walletAddress: nil)) - #expect(storedCredentials?.walletId == wallet.id) - #expect(storedCredentials?.walletAddress == wallet.address) + #expect(fixture.client.session == nil) + #expect(storedCredentials?.wallet.id == wallet.id) + #expect(storedCredentials?.wallet.address == wallet.address) #expect(storedCredentials?.expiresAt == expiresAt) #expect(isEmailAuth(storedCredentials?.auth)) #expect(fixture.signer.clearCallCount == 1) @@ -349,8 +351,7 @@ private func isOidcAuth( @Test @MainActor func TestWalletRestoredExpiredSessionIsInactiveAndReplaysObserver() async throws { let storedCredentials = StorableCredentials( - walletId: "wallet-expired", - walletAddress: "0x1111111111111111111111111111111111111111", + wallet: activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-expired"), signerCredentialId: "0xmock-credential", alg: .ecdsaP256Sha256, expiresAt: "2026-01-01T00:00:00Z", @@ -368,11 +369,11 @@ private func isOidcAuth( defer { observation.cancel() } let replayedEvent = try await waitForSessionExpiredEvent { expiredEvent } - #expect(fixture.client.session == OMSWalletSessionState(walletAddress: nil)) - #expect(replayedEvent?.session.walletAddress == storedCredentials.walletAddress) + #expect(fixture.client.session == nil) + #expect(replayedEvent?.wallet?.address == storedCredentials.wallet.address) #expect(isEmailAuth(replayedEvent?.session.auth)) #expect(replayedEvent?.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) - #expect(try fixture.storedCredentials()?.walletId == storedCredentials.walletId) + #expect(try fixture.storedCredentials()?.wallet.id == storedCredentials.wallet.id) #expect(fixture.signer.clearCallCount == 1) } @@ -385,8 +386,7 @@ private func isOidcAuth( let keychain = InMemoryKeychain() let signer = MockCredentialSigner() let credentials = StorableCredentials( - walletId: "wallet-id", - walletAddress: "0xwallet", + wallet: activeTestWallet("0xwallet", id: "wallet-id"), signerCredentialId: "0xmock-credential", alg: .ecdsaP256Sha256, expiresAt: "2099-01-01T00:00:00Z", @@ -419,8 +419,7 @@ private func isOidcAuth( @Test @MainActor func TestWalletSessionExpiredReplayClearsOnNewAuthFlow() async throws { let storedCredentials = StorableCredentials( - walletId: "wallet-expired", - walletAddress: "0x1111111111111111111111111111111111111111", + wallet: activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-expired"), signerCredentialId: "0xmock-credential", alg: .ecdsaP256Sha256, expiresAt: "2026-01-01T00:00:00Z", @@ -436,7 +435,7 @@ private func isOidcAuth( } let initialReplay = try await waitForSessionExpiredEvent { expiredEvent } expiredObservation.cancel() - #expect(initialReplay?.session.walletAddress == storedCredentials.walletAddress) + #expect(initialReplay?.wallet?.address == storedCredentials.wallet.address) try fixture.transport.enqueue( CommitVerifierResponse( @@ -501,10 +500,10 @@ private func waitForSessionExpiredEvent( defer { observation.cancel() } let replayedEvent = try await waitForSessionExpiredEvent { expiredEvent } - #expect(replayedEvent?.session.walletAddress == wallet.address) + #expect(replayedEvent?.wallet?.address == wallet.address) #expect(isEmailAuth(replayedEvent?.session.auth)) - #expect(fixture.client.session == OMSWalletSessionState(walletAddress: nil)) - #expect(try fixture.storedCredentials()?.walletId == wallet.id) + #expect(fixture.client.session == nil) + #expect(try fixture.storedCredentials()?.wallet.id == wallet.id) } @Test func TestWalletCompleteEmailAuthFetchesPaginatedWallets() async throws { @@ -682,7 +681,7 @@ private func waitForSessionExpiredEvent( #expect(error.operation == .pendingWalletSelectionCreateAndSelectWallet) #expect(fixture.transport.requestCount(for: WaasAPI.CreateWallet.urlPath) == 0) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) #expect(try fixture.storedCredentials() == nil) } catch { #expect(Bool(false)) @@ -750,8 +749,8 @@ private func waitForSessionExpiredEvent( #expect(createWalletRequest.reference == "reference-1") #expect(created.wallet.id == createdWallet.id) #expect(fixture.client.walletId == createdWallet.id) - #expect(fixture.client.walletAddress == createdWallet.address) - #expect(isEmailAuth(fixture.client.session.auth)) + #expect(fixture.client.activeWallet?.address == createdWallet.address) + #expect(isEmailAuth(fixture.client.session?.auth)) } @Test func TestWalletCompleteEmailAuthSignsOutWhenActivationFails() async throws { @@ -780,10 +779,10 @@ private func waitForSessionExpiredEvent( } #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) #expect(fixture.client.verifier == "") #expect(fixture.client.challenge == "") - #expect(fixture.client.session == OMSWalletSessionState(walletAddress: nil)) + #expect(fixture.client.session == nil) #expect(fixture.signer.clearCallCount == 1) #expect(fixture.signer.hasStoredCredential == false) #expect(try fixture.storedCredentials() == nil) @@ -792,7 +791,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSignInWithOidcIdTokenCommitsCompletesAndResolvesWallet() async throws { let fixture = makeMockWalletClient() let idToken = try fakeOidcIdToken() - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -823,7 +822,7 @@ private func waitForSessionExpiredEvent( issuer: "https://accounts.google.com", audience: "demo-web-client-id" ) - guard case .walletSelected(_, let wallet, _, _) = result else { + guard case .walletSelected(let wallet, _, _) = result else { #expect(Bool(false)) return } @@ -858,8 +857,8 @@ private func waitForSessionExpiredEvent( #expect(useWalletRequest.walletId == selectedWallet.id) #expect(wallet.id == selectedWallet.id) #expect(fixture.client.walletId == selectedWallet.id) - #expect(fixture.client.walletAddress == selectedWallet.address) - #expect(isOidcAuth(fixture.client.session.auth, flow: .idToken)) + #expect(fixture.client.activeWallet?.address == selectedWallet.address) + #expect(isOidcAuth(fixture.client.session?.auth, flow: .idToken)) #expect(isOidcAuth(storedCredentials?.auth, flow: .idToken)) } @@ -887,7 +886,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSignInWithOidcIdTokenCanReturnManualWalletSelection() async throws { let fixture = makeMockWalletClient() - let availableWallet = testWallet(id: "wallet-def", address: "0xdef") + let availableWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -920,8 +919,8 @@ private func waitForSessionExpiredEvent( #expect(pendingSelection.wallets.map(\.id) == [availableWallet.id]) #expect(pendingSelection.credential.credentialId == testCredential.credentialId) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) - #expect(fixture.client.session.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) + #expect(fixture.client.session == nil) #expect(try fixture.storedCredentials() == nil) #expect(fixture.transport.requestCount(for: WaasAPI.UseWallet.urlPath) == 0) #expect(fixture.transport.requestCount(for: WaasAPI.CreateWallet.urlPath) == 0) @@ -929,7 +928,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSignInWithOidcIdTokenStoresCustomProviderMetadata() async throws { let fixture = makeMockWalletClient() - let selectedWallet = testWallet(id: "wallet-custom-oidc", address: "0xcustomoidc") + let selectedWallet = testWallet(id: "wallet-custom-oidc", address: "0xc057000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -961,7 +960,7 @@ private func waitForSessionExpiredEvent( let storedCredentials = try fixture.storedCredentials() #expect(isOidcAuth( - fixture.client.session.auth, + fixture.client.session?.auth, flow: .idToken, issuer: "https://issuer.example", provider: "custom", @@ -996,7 +995,7 @@ private func waitForSessionExpiredEvent( signerKeyType: .ecdsaP256Sha256 ) ) - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -1059,10 +1058,10 @@ private func waitForSessionExpiredEvent( } #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) #expect(fixture.client.verifier == "") #expect(fixture.client.challenge == "") - #expect(fixture.client.session == OMSWalletSessionState(walletAddress: nil)) + #expect(fixture.client.session == nil) #expect(fixture.signer.hasStoredCredential == false) #expect(try fixture.storedCredentials() == nil) } @@ -1278,8 +1277,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletStartOidcRedirectAuthUsesStoredEmailAsGoogleLoginHint() async throws { let storedCredentials = StorableCredentials( - walletId: "wallet-google", - walletAddress: "0x1111111111111111111111111111111111111111", + wallet: activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-google"), signerCredentialId: "0xmock-credential", alg: .ecdsaP256Sha256, expiresAt: "2099-01-01T00:00:00Z", @@ -1321,7 +1319,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletOidcRedirectAuthCodeModeOmitsPkceAndUsesPendingAuthMode() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-auth-code", address: "0xauthcode") + let selectedWallet = testWallet(id: "wallet-auth-code", address: "0xa7c0de0000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -1372,7 +1370,7 @@ private func waitForSessionExpiredEvent( let result = try await fixture.client.handleOIDCRedirectCallback( "omsclientswiftdemo://auth/callback?code=auth-code&state=\(try oidcState(started))" ) - guard case .completed(.walletSelected(_, let wallet, _, _)) = result else { + guard case .completed(.walletSelected(let wallet, _, _)) = result else { #expect(Bool(false)) return } @@ -1388,8 +1386,8 @@ private func waitForSessionExpiredEvent( @Test func TestWalletHandleOidcRedirectCallbackCompletesAuthActivatesWalletAndClearsPendingAuth() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") - let secondWallet = testWallet(id: "wallet-ghi", address: "0xghi") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") + let secondWallet = testWallet(id: "wallet-ghi", address: "0x0b10000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -1421,7 +1419,7 @@ private func waitForSessionExpiredEvent( let result = try await fixture.client.handleOIDCRedirectCallback( "omsclientswiftdemo://auth/callback?code=auth-code&state=\(try oidcState(started))&scope=openid" ) - guard case .completed(.walletSelected(_, let wallet, let wallets, let credential)) = result else { + guard case .completed(.walletSelected(let wallet, let wallets, let credential)) = result else { #expect(Bool(false)) return } @@ -1441,14 +1439,14 @@ private func waitForSessionExpiredEvent( #expect(completeAuthRequest.answer == "auth-code") #expect(completeAuthRequest.lifetime == 604_800) #expect(useWalletRequest.walletId == selectedWallet.id) - #expect(wallet.address == "0xdef") + #expect(wallet.address == "0xdef0000000000000000000000000000000000000") #expect(wallets.map(\.id) == [selectedWallet.id, secondWallet.id]) #expect(credential.credentialId == testCredential.credentialId) - #expect(fixture.client.walletAddress == "0xdef") + #expect(fixture.client.activeWallet?.address == "0xdef0000000000000000000000000000000000000") #expect(fixture.client.walletId == "wallet-def") #expect(fixture.oidcRedirectAuthStore.pending == nil) - #expect(storedCredentials?.walletId == "wallet-def") - #expect(storedCredentials?.walletAddress == "0xdef") + #expect(storedCredentials?.wallet.id == "wallet-def") + #expect(storedCredentials?.wallet.address == "0xdef0000000000000000000000000000000000000") #expect( isOidcAuth( storedCredentials?.auth, @@ -1462,7 +1460,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletHandleOidcRedirectCallbackCompletesAuthFromFragmentResponseMode() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-fragment", address: "0xfragment") + let selectedWallet = testWallet(id: "wallet-fragment", address: "0xf4a6000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -1496,7 +1494,7 @@ private func waitForSessionExpiredEvent( let result = try await fixture.client.handleOIDCRedirectCallback( "omsclientswiftdemo://auth/callback#code=fragment-code&state=\(try oidcState(started))&scope=openid" ) - guard case .completed(.walletSelected(_, let wallet, _, _)) = result else { + guard case .completed(.walletSelected(let wallet, _, _)) = result else { #expect(Bool(false)) return } @@ -1513,11 +1511,11 @@ private func waitForSessionExpiredEvent( @Test func TestWalletHandleOidcRedirectCallbackCanReturnPendingWalletSelectionWithoutActivatingWallet() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") let otherTypeWallet = testWallet( id: "wallet-other", type: .solana, - address: "0xother" + address: "0x07e4000000000000000000000000000000000000" ) try fixture.transport.enqueue( CommitVerifierResponse( @@ -1556,8 +1554,8 @@ private func waitForSessionExpiredEvent( #expect(pendingSelection.wallets.map(\.id) == ["wallet-def"]) #expect(pendingSelection.credential.credentialId == testCredential.credentialId) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) - #expect(fixture.client.session.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) + #expect(fixture.client.session == nil) #expect(fixture.oidcRedirectAuthStore.pending == nil) #expect(fixture.transport.requestCount(for: WaasAPI.UseWallet.urlPath) == 0) #expect(try fixture.storedCredentials() == nil) @@ -1565,11 +1563,11 @@ private func waitForSessionExpiredEvent( @Test func TestWalletHandleOidcRedirectCallbackUsesPendingCompletionPreferences() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") let otherTypeWallet = testWallet( id: "wallet-other", type: .solana, - address: "0xother" + address: "0x07e4000000000000000000000000000000000000" ) try fixture.transport.enqueue( CommitVerifierResponse( @@ -1620,7 +1618,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletHandleOidcRedirectCallbackOverridesPendingCompletionPreferences() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -1656,7 +1654,7 @@ private func waitForSessionExpiredEvent( walletSelection: .automatic, sessionLifetimeSeconds: 300 ) - guard case .completed(.walletSelected(_, let wallet, _, _)) = result else { + guard case .completed(.walletSelected(let wallet, _, _)) = result else { #expect(Bool(false)) return } @@ -1781,7 +1779,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletHandleOidcRedirectCallbackConsumesPendingAuthWhenCancelled() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - let selectedWallet = testWallet(id: "wallet-def", address: "0xdef") + let selectedWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -1819,7 +1817,7 @@ private func waitForSessionExpiredEvent( } #expect(fixture.oidcRedirectAuthStore.pending == nil) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) #expect(fixture.signer.clearCallCount == 2) #expect(fixture.signer.hasStoredCredential == false) } @@ -1854,7 +1852,7 @@ private func waitForSessionExpiredEvent( #expect(error.underlyingError as? OIDCRedirectAuthError == .providerError("User cancelled")) } #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == nil) + #expect(fixture.client.activeWallet?.address == nil) #expect(fixture.oidcRedirectAuthStore.pending == nil) } @@ -1955,7 +1953,7 @@ private func waitForSessionExpiredEvent( try await fixture.client.callContract( network: .polygonAmoy, contract: "0xcontract", - method: "mint(address)", + method: "mint", args: nil ) } @@ -1989,7 +1987,7 @@ private func waitForSessionExpiredEvent( try await walletIdOnlyFixture.client.callContract( network: .polygonAmoy, contract: "0xcontract", - method: "mint(address)", + method: "mint", args: nil, selectFeeOption: .firstAvailable ) @@ -2111,7 +2109,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletOwnerSmartSessionFlowUsesPublicAndSignedWaasMethods() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") let transfer = WaasGenerated.Grant( kind: .nativeTransfer, nativeTransfer: WaasGenerated.NativeTransferGrant( @@ -2196,7 +2194,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSolanaOperationsUseSolanaRequestShapes() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "solana-wallet" - fixture.client.walletAddress = "3gFktQX6vki5M2DzN8Y1ESPUJ4fJ8o6hVQWf8vYvPypD" + fixture.client.activeWallet = activeTestWallet("3gFktQX6vki5M2DzN8Y1ESPUJ4fJ8o6hVQWf8vYvPypD") try fixture.transport.enqueue( WaasGenerated.SignMessageResponse(signature: "solana-signature"), for: WaasAPI.SignMessage.urlPath @@ -2222,7 +2220,7 @@ private func waitForSessionExpiredEvent( let signature = try await fixture.client.signSolanaMessage(message: "hello") let valid = try await fixture.client.isValidSolanaMessageSignature( - walletAddress: fixture.client.walletAddress!, + walletAddress: fixture.client.activeWallet!.address, message: "hello", signature: signature ) @@ -2261,7 +2259,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSolanaFirstAvailableUsesIndexerBalances() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "solana-wallet" - fixture.client.walletAddress = "4Nd1mYQbqjVU2aR7cJNPyqW9XjHnBYvWQd7ZxYxvT6uP" + fixture.client.activeWallet = activeTestWallet("4Nd1mYQbqjVU2aR7cJNPyqW9XjHnBYvWQd7ZxYxvT6uP") let usdcMint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" try fixture.indexerBackend.setSolanaBalancesResponse( """ @@ -2358,7 +2356,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionDefaultSelectsFirstFeeOptionIdentifierWithoutBalanceLookup() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2405,7 +2403,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletGetTransactionStatusKeepsCancellationError() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") fixture.transport.enqueueCancellation(for: WaasAPI.TransactionStatusMethod.urlPath) do { @@ -2420,7 +2418,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionFirstAvailableSelectsFirstFundedFeeOption() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2486,7 +2484,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionFirstAvailableRequiresFundedFeeOption() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2589,7 +2587,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionCustomSelectorReceivesFeeOptionBalances() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2673,7 +2671,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionUnsponsoredCustomSelectorRequiresSelection() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2707,7 +2705,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionSponsoredInvokesCustomFeeSelectorWithEmptyOptions() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2752,7 +2750,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionSponsoredContinuesWithFirstAvailable() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2783,7 +2781,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionSponsoredDoesNotExecuteWhenAcknowledgementThrows() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2816,7 +2814,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletCallContractReturnsSendTransactionResponse() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2840,7 +2838,7 @@ private func waitForSessionExpiredEvent( let txResult = try await fixture.client.callContract( network: .polygonAmoy, contract: "0xcontract", - method: "mint(address)", + method: "mint", args: [AbiArg(type: "address", value: .string("0xrecipient"))], mode: .native ) @@ -2865,7 +2863,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionCanSkipStatusPolling() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2898,7 +2896,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletCallContractCanSkipStatusPolling() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2918,7 +2916,7 @@ private func waitForSessionExpiredEvent( let txResult = try await fixture.client.callContract( network: .polygonAmoy, contract: "0xcontract", - method: "mint(address)", + method: "mint", args: [AbiArg(type: "address", value: .string("0xrecipient"))], waitForStatus: false ) @@ -2933,7 +2931,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionMarksTimedOutWhenPollingDeadlineExpires() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -2975,7 +2973,7 @@ private func waitForSessionExpiredEvent( for (index, status) in statuses.enumerated() { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( txnId: "txn-timeout-\(index)", @@ -3017,7 +3015,7 @@ private func waitForSessionExpiredEvent( for (index, options) in invalidOptions.enumerated() { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( txnId: "txn-invalid-polling-\(index)", @@ -3048,7 +3046,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionReturnsWhenPollingFindsHashBeforeExecuted() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -3084,7 +3082,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionReturnsFailedStatusAsTerminal() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -3120,7 +3118,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionExecutedFastPathAcceptsStatusHash() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -3156,7 +3154,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionUnsponsoredWithoutFeeOptionsThrows() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet") try fixture.transport.enqueue( PrepareResponse( @@ -3346,6 +3344,8 @@ func testWallet( networkFamily = .evm case .solana: networkFamily = .solana + case .tron: + networkFamily = .tron case .unknown(let value): networkFamily = .unknown(value) } @@ -3358,6 +3358,21 @@ func testWallet( ) } +/// An SDK `Wallet` for seeding `WalletClient.activeWallet` directly. The type defaults to Ethereum +/// for `0x` addresses and Solana otherwise. +func activeTestWallet( + _ address: String, + id: String = "test-wallet", + type: WalletType? = nil +) -> Wallet { + Wallet( + id: id, + type: type ?? (address.hasPrefix("0x") ? .ethereum : .solana), + address: address, + keyOrigin: .enclave + ) +} + func testFeeOptions() -> [FeeOption] { [ FeeOption( @@ -3433,6 +3448,8 @@ final class MockIndexerBackend: @unchecked Sendable { private var balanceRequests: [RecordedBalanceRequest] = [] private var solanaBalancesResponse = Data(#"{"balances":[],"errors":[]}"#.utf8) private var solanaBalanceRequests: [RecordedBalanceRequest] = [] + private var tronBalancesResponse = Data(#"{"balances":[],"errors":[]}"#.utf8) + private var tronBalanceRequestBodies: [Data] = [] var nativeBalanceRequestCount: Int { withLock { balanceRequests.count } @@ -3454,6 +3471,14 @@ final class MockIndexerBackend: @unchecked Sendable { withLock { solanaBalanceRequests.flatMap(\.contractAddresses) } } + var tronBalanceRequestPayloads: [NSDictionary] { + withLock { + tronBalanceRequestBodies.compactMap { + try? JSONSerialization.jsonObject(with: $0) as? NSDictionary + } + } + } + func makeClient( publishableKey: String, environment: OMSWalletEnvironment @@ -3469,7 +3494,8 @@ final class MockIndexerBackend: @unchecked Sendable { let indexerEnvironment = OMSWalletEnvironment( walletApiUrl: environment.walletApiUrl, indexerGatewayUrl: "https://\(host)/v1/IndexerGateway/", - solanaIndexerGatewayUrl: "https://\(host)/v1/SolanaIndexerGateway/" + solanaIndexerGatewayUrl: "https://\(host)/v1/SolanaIndexerGateway/", + tronIndexerGatewayUrl: "https://\(host)/v1/TronIndexerGateway/" ) return IndexerClient( @@ -3499,8 +3525,20 @@ final class MockIndexerBackend: @unchecked Sendable { } } + func setTronBalancesResponse(_ body: String) throws { + let data = Data(body.utf8) + _ = try JSONSerialization.jsonObject(with: data) + withLock { + tronBalancesResponse = data + } + } + func responseBody(for request: URLRequest) -> Data { withLock { + if request.url?.path.contains("/TronIndexerGateway/") == true { + tronBalanceRequestBodies.append(Self.bodyData(for: request) ?? Data()) + return tronBalancesResponse + } if request.url?.path.contains("/SolanaIndexerGateway/") == true { solanaBalanceRequests.append(decodeBalanceRequest(Self.bodyData(for: request))) return solanaBalancesResponse @@ -3918,6 +3956,22 @@ final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { return try WebRPCJSON.makeDecoder().decode(T.self, from: requests[index].body) } + /// The raw JSON object of a recorded request body, for asserting exact wire shapes. + func requestObject(for path: String, at index: Int = 0) throws -> NSDictionary { + let requests = recordedRequests(for: path) + guard requests.indices.contains(index), + let object = try JSONSerialization.jsonObject(with: requests[index].body) as? NSDictionary else { + throw MockError.missingRecordedRequest(path, index) + } + return object + } + + var totalRequestCount: Int { + lock.lock() + defer { lock.unlock() } + return recordedRequests.count + } + func decodedRequests( _ type: T.Type, for path: String diff --git a/Tests/OMSWalletTests/PublicErrorContractsTests.swift b/Tests/OMSWalletTests/PublicErrorContractsTests.swift index 8fc2ad3..5734e1b 100644 --- a/Tests/OMSWalletTests/PublicErrorContractsTests.swift +++ b/Tests/OMSWalletTests/PublicErrorContractsTests.swift @@ -46,7 +46,7 @@ import Testing ) ) fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") await expectPublicError( try await fixture.client.getWalletImportRecipientKey(cipherSuite: .p256Sha256Aes256Gcm), @@ -323,8 +323,9 @@ import Testing let now = Date(timeIntervalSince1970: 1_800_000_000) let expiredFixture = makeMockWalletClient(currentDate: { now }) expiredFixture.client.walletId = "wallet-main" - expiredFixture.client.walletAddress = "0xwallet" + expiredFixture.client.activeWallet = activeTestWallet("0xwallet") expiredFixture.client.sessionExpiresAt = "2025-01-01T00:00:00Z" + expiredFixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) await expectPublicError( try await expiredFixture.client.signMessage(network: .polygon, message: "hello"), @@ -895,8 +896,9 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda private func makeRestoredWalletClient() -> MockWalletClientFixture { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) return fixture } diff --git a/Tests/OMSWalletTests/RequestsTests.swift b/Tests/OMSWalletTests/RequestsTests.swift index 866e506..be6b13d 100644 --- a/Tests/OMSWalletTests/RequestsTests.swift +++ b/Tests/OMSWalletTests/RequestsTests.swift @@ -97,39 +97,81 @@ import Testing #expect(try parseUnits(value: "0.0000000000000000005", decimals: 18) == "1") } -@Test func TestOMSWalletSessionStateParsesExpiresAt() throws { - let state = OMSWalletSessionState( - walletAddress: "0xabc", - expiresAtString: "2026-01-01T00:00:00Z", +@Test func TestOMSWalletSessionParsesExpiresAt() throws { + let expiresAt = try #require(OMSWalletSession.parseDate("2026-01-01T00:00:00Z")) + let session = OMSWalletSession( + expiresAt: expiresAt, auth: .email(OMSWalletEmailSessionAuth(email: "user@example.com")) ) - #expect(state.walletAddress == "0xabc") - #expect(state.expiresAt == Date(timeIntervalSince1970: 1_767_225_600)) - #expect(state.auth == .email(OMSWalletEmailSessionAuth(email: "user@example.com"))) - #expect(state.auth?.email == "user@example.com") + #expect(session.expiresAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(OMSWalletSession.parseDate("2026-01-01T00:00:00.500Z") == Date(timeIntervalSince1970: 1_767_225_600.5)) + #expect(OMSWalletSession.parseDate("not-a-date") == nil) + #expect(session.auth.email == "user@example.com") } @Test func TestStorableCredentialsRoundTripSessionMetadata() throws { + let wallet = Wallet( + id: "wallet-1", + type: .tron, + address: "TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H", + reference: "main", + keyOrigin: .imported + ) let credentials = StorableCredentials( - walletId: "wallet-1", - walletAddress: "0xabc", + wallet: wallet, signerCredentialId: "0xsigner", alg: .ecdsaP256Sha256, expiresAt: "2026-01-01T00:00:00Z", auth: .email(OMSWalletEmailSessionAuth(email: "user@example.com")) ) - let restored = try StorableCredentials.from(jsonString: credentials.jsonString()) + let json = try credentials.jsonString() + let object = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let restored = try StorableCredentials.from(jsonString: json) - #expect(restored.walletId == "wallet-1") - #expect(restored.walletAddress == "0xabc") + #expect(object["version"] as? Int == 2) + #expect(object["walletId"] == nil) + #expect(object["walletAddress"] == nil) + #expect(restored.version == 2) + #expect(restored.wallet == wallet) #expect(restored.signerCredentialId == "0xsigner") #expect(restored.alg == .ecdsaP256Sha256) #expect(restored.expiresAt == "2026-01-01T00:00:00Z") #expect(restored.auth == .email(OMSWalletEmailSessionAuth(email: "user@example.com"))) } +@Test func TestStorableCredentialsRejectsVersionOneRecords() { + let versionOne = """ + {"walletId":"wallet-1","walletAddress":"0x1111111111111111111111111111111111111111",\ + "signerCredentialId":"0xsigner","alg":"ecdsa-p256-sha256","expiresAt":"2099-01-01T00:00:00Z",\ + "auth":{"type":"email","email":"user@example.com"}} + """ + + #expect(throws: DecodingError.self) { + try StorableCredentials.from(jsonString: versionOne) + } +} + +@Test(arguments: [ + #"{"id":"wallet-1","type":"bitcoin","address":"bc1q","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","type":"ethereum","address":"0x1111111111111111111111111111111111111111","keyOrigin":"derived"}"#, + #"{"id":"wallet-1","type":"ethereum","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"#, + #"{"id":"","type":"tron","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","type":"tron","address":"","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"# +]) +func TestStorableCredentialsRejectsInvalidStoredWallets(walletJSON: String) { + let record = """ + {"version":2,"wallet":\(walletJSON),"signerCredentialId":"0xsigner","alg":"ecdsa-p256-sha256",\ + "expiresAt":"2099-01-01T00:00:00Z","auth":{"type":"email","email":"user@example.com"}} + """ + + #expect(throws: (any Error).self) { + try StorableCredentials.from(jsonString: record) + } +} + @Test func TestOidcRedirectAuthMatchesCustomSchemesWithoutAuthority() throws { #expect( OIDCRedirectAuth.matchesRedirectURI( diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift new file mode 100644 index 0000000..0d7922d --- /dev/null +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -0,0 +1,584 @@ +import CryptoKit +import Foundation +import Testing +@testable import OMSWallet + +private let tronWalletAddress = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" +private let tronWalletHex = "0x8840e6c55b9ada326d211d818c34a994aeced808" +private let tronRecipient = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" +private let tronUsdt = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" + +private final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withValue(_ body: (inout Value) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body(&value) + } +} + +private func makeActiveWalletFixture( + type: WalletType = .tron, + address: String = tronWalletAddress +) -> MockWalletClientFixture { + let fixture = makeMockWalletClient() + fixture.client.walletId = "wallet-id" + fixture.client.activeWallet = Wallet(id: "wallet-id", type: type, address: address, keyOrigin: .enclave) + fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + return fixture +} + +private func sponsoredPrepareResponse(_ txnId: String) -> PrepareResponse { + PrepareResponse( + txnId: txnId, + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2099-01-01T00:00:00Z" + ) +} + +private func expectValidationError( + _ operation: OMSWalletOperation, + _ body: () async throws -> T +) async { + do { + _ = try await body() + Issue.record("Expected a validation error for \(operation.rawValue)") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + #expect(error.operation == operation) + } catch { + Issue.record("Expected OMSWalletError, got \(error)") + } +} + +@Test func TestTronSendTransactionOmitsDataForPlainTrxTransferInNativeMode() async throws { + let fixture = makeActiveWalletFixture() + try fixture.transport.enqueue(sponsoredPrepareResponse("txn-trx"), for: WaasAPI.PrepareTronTransaction.urlPath) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + try fixture.transport.enqueue( + WaasTransactionStatusResponse(status: .executed, txnHash: "tron-txid"), + for: WaasAPI.TransactionStatusMethod.urlPath + ) + + let result = try await fixture.client.sendTronTransaction( + network: .nile, + to: tronRecipient, + value: "1000000" + ) + + let prepare = try fixture.transport.requestObject(for: WaasAPI.PrepareTronTransaction.urlPath) + let execute = try fixture.transport.requestObject(for: WaasAPI.Execute.urlPath) + let expectedPrepare: NSDictionary = [ + "network": "tron:nile", + "walletId": "wallet-id", + "to": tronRecipient, + "value": "1000000", + "mode": "native" + ] + #expect(prepare == expectedPrepare) + #expect(prepare["data"] == nil) + #expect(execute == (["txnId": "txn-trx"] as NSDictionary)) + #expect(result.txnId == "txn-trx") + #expect(result.status == .executed) + #expect(result.txnHash == "tron-txid") + #expect(result.statusResolution == .resolved) +} + +@Test func TestTronSendTransactionForwardsEmptyDataAsPayableFallbackCall() async throws { + let fixture = makeActiveWalletFixture() + try fixture.transport.enqueue(sponsoredPrepareResponse("txn-fallback"), for: WaasAPI.PrepareTronTransaction.urlPath) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + + let result = try await fixture.client.sendTronTransaction( + network: .nile, + to: tronUsdt, + data: "0x", + waitForStatus: false + ) + + let prepare = try fixture.transport.requestObject(for: WaasAPI.PrepareTronTransaction.urlPath) + let expectedPrepare: NSDictionary = [ + "network": "tron:nile", + "walletId": "wallet-id", + "to": tronUsdt, + "value": "0", + "data": "0x", + "mode": "native" + ] + #expect(prepare == expectedPrepare) + #expect(result.txnId == "txn-fallback") + #expect(result.status == .pending) +} + +@Test func TestTronCallContractPreparesTrc20TransferThroughWalletServiceEncoder() async throws { + let fixture = makeActiveWalletFixture() + try fixture.transport.enqueue(sponsoredPrepareResponse("txn-trc20"), for: WaasAPI.PrepareTronContractCall.urlPath) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + + let result = try await fixture.client.callTronContract( + network: .mainnet, + contract: tronUsdt, + method: "transfer", + args: [ + AbiArg(type: "address", value: .string(tronRecipient)), + AbiArg(type: "uint256", value: .string("1000000")) + ], + waitForStatus: false + ) + + let prepare = try fixture.transport.requestObject(for: WaasAPI.PrepareTronContractCall.urlPath) + let expectedPrepare: NSDictionary = [ + "network": "tron:mainnet", + "walletId": "wallet-id", + "contract": tronUsdt, + "method": "transfer", + "args": [ + ["type": "address", "value": tronRecipient], + ["type": "uint256", "value": "1000000"] + ], + "mode": "native" + ] + #expect(prepare == expectedPrepare) + #expect(result.txnId == "txn-trc20") +} + +@Test(arguments: ["transfer(address,uint256)", "transfer ", "", "1transfer", "trans-fer"]) +func TestContractCallsRejectNonBareMethodNamesBeforeAnyRequest(method: String) async throws { + let tron = makeActiveWalletFixture() + await expectValidationError(.walletCallTronContract) { + try await tron.client.callTronContract( + network: .nile, + contract: tronUsdt, + method: method, + args: [AbiArg(type: "uint256", value: .string("1"))] + ) + } + + let ethereum = makeActiveWalletFixture(type: .ethereum, address: "0x9999999999999999999999999999999999999999") + await expectValidationError(.walletCallContract) { + try await ethereum.client.callContract( + network: .polygon, + contract: "0x1111111111111111111111111111111111111111", + method: method, + args: nil + ) + } + + #expect(tron.transport.totalRequestCount == 0) + #expect(ethereum.transport.totalRequestCount == 0) +} + +@Test func TestContractCallsAcceptBareMethodNames() async throws { + let fixture = makeActiveWalletFixture() + for method in ["transfer", "_mint", "balanceOf2"] { + try fixture.transport.enqueue(sponsoredPrepareResponse("txn-\(method)"), for: WaasAPI.PrepareTronContractCall.urlPath) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + let result = try await fixture.client.callTronContract( + network: .nile, + contract: tronUsdt, + method: method, + waitForStatus: false + ) + #expect(result.txnId == "txn-\(method)") + } +} + +@Test func TestTronSponsoredTransactionPassesEmptyFeeOptionsToSelector() async throws { + let fixture = makeActiveWalletFixture() + try fixture.transport.enqueue(sponsoredPrepareResponse("txn-sponsored"), for: WaasAPI.PrepareTronTransaction.urlPath) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + let receivedOptions = LockedBox<[[FeeOptionWithBalance]]>([]) + + _ = try await fixture.client.sendTronTransaction( + network: .nile, + to: tronRecipient, + value: "1", + selectFeeOption: FeeOptionSelector { options in + receivedOptions.withValue { $0.append(options) } + return nil + }, + waitForStatus: false + ) + + let execute = try fixture.transport.requestObject(for: WaasAPI.Execute.urlPath) + #expect(receivedOptions.withValue { $0.map(\.count) } == [0]) + #expect(execute == (["txnId": "txn-sponsored"] as NSDictionary)) + #expect(fixture.indexerBackend.tronBalanceRequestPayloads.isEmpty) +} + +@Test func TestTronFirstAvailablePaysUnsponsoredFeeWithTrxBalance() async throws { + let fixture = makeActiveWalletFixture() + try fixture.indexerBackend.setTronBalancesResponse( + """ + { + "balances": [ + { + "network": "tron:nile", + "accountAddress": "\(tronWalletAddress)", + "assetType": "native", + "name": "Tron", + "symbol": "TRX", + "decimals": 6, + "balance": "2000000", + "formattedBalance": "2", + "verificationStatus": "unknown", + "verificationSource": "none" + } + ], + "errors": [] + } + """ + ) + try fixture.transport.enqueue( + PrepareResponse( + txnId: "txn-burn", + status: .quoted, + feeOptions: [ + WaasFeeOption( + token: WaasFeeToken(network: "tron:nile", name: "TRX", symbol: "TRX", type: "NATIVE"), + value: "345000", + displayValue: "0.345" + ) + ], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z" + ), + for: WaasAPI.PrepareTronTransaction.urlPath + ) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + let receivedOptions = LockedBox<[FeeOptionWithBalance]>([]) + + let result = try await fixture.client.sendTronTransaction( + network: .nile, + to: tronRecipient, + value: "1000000", + selectFeeOption: FeeOptionSelector { options in + receivedOptions.withValue { $0 = options } + return try await FeeOptionSelector.firstAvailable(options) + }, + waitForStatus: false + ) + + let execute = try fixture.transport.requestObject(for: WaasAPI.Execute.urlPath) + let option = try #require(receivedOptions.withValue { $0.first }) + let expectedIndexerRequest: NSDictionary = [ + "networks": ["tron:nile"], + "filter": ["accountAddresses": [tronWalletAddress], "omitNativeBalances": false], + "omitMetadata": true + ] + #expect(fixture.indexerBackend.tronBalanceRequestPayloads == [expectedIndexerRequest]) + #expect(option.availableRaw == "2000000") + #expect(option.available == "2") + #expect(option.decimals == 6) + #expect(execute == (["txnId": "txn-burn", "feeOption": ["token": "TRX", "index": 0]] as NSDictionary)) + #expect(result.txnId == "txn-burn") +} + +@Test func TestTronFeeBalancesLookUpTrc20ByContractAddress() async throws { + let fixture = makeActiveWalletFixture() + try fixture.indexerBackend.setTronBalancesResponse( + """ + { + "balances": [ + { + "network": "tron:nile", + "accountAddress": "\(tronWalletAddress)", + "assetType": "fungible-token", + "tokenStandard": "trc20", + "contractAddress": "\(tronUsdt)", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "balance": "5000000", + "formattedBalance": "5", + "verificationStatus": "unknown", + "verificationSource": "none" + } + ], + "errors": [] + } + """ + ) + try fixture.transport.enqueue( + PrepareResponse( + txnId: "txn-usdt-fee", + status: .quoted, + feeOptions: [ + WaasFeeOption( + token: WaasFeeToken( + network: "tron:nile", + name: "Tether USD", + symbol: "USDT", + type: "TRC20", + decimals: 6, + contractAddress: tronUsdt + ), + value: "1000000", + displayValue: "1" + ) + ], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z" + ), + for: WaasAPI.PrepareTronContractCall.urlPath + ) + try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + + _ = try await fixture.client.callTronContract( + network: .nile, + contract: tronUsdt, + method: "transfer", + args: [ + AbiArg(type: "address", value: .string(tronRecipient)), + AbiArg(type: "uint256", value: .string("1")) + ], + selectFeeOption: .firstAvailable, + waitForStatus: false + ) + + let execute = try fixture.transport.requestObject(for: WaasAPI.Execute.urlPath) + let expectedIndexerRequest: NSDictionary = [ + "networks": ["tron:nile"], + "filter": [ + "accountAddresses": [tronWalletAddress], + "omitNativeBalances": true, + "contractWhitelist": [tronUsdt] + ], + "omitMetadata": true + ] + #expect(fixture.indexerBackend.tronBalanceRequestPayloads == [expectedIndexerRequest]) + #expect(execute == (["txnId": "txn-usdt-fee", "feeOption": ["token": "USDT", "index": 0]] as NSDictionary)) +} + +@Test func TestTronSignsMessagesAndTypedDataWithoutEvmNetwork() async throws { + let fixture = makeActiveWalletFixture() + try fixture.transport.enqueue(SignMessageResponse(signature: "0xtron-signature"), for: WaasAPI.SignMessage.urlPath) + try fixture.transport.enqueue(SignTypedDataResponse(signature: "0xtron-typed"), for: WaasAPI.SignTypedData.urlPath) + let typedData: JSONValue = .object([ + "domain": .object([ + "name": .string("Example"), + "chainId": .integer(3_448_148_188), + "verifyingContract": .string(tronUsdt) + ]), + "types": .object(["Mail": .array([.object(["name": .string("to"), "type": .string("address")])])]), + "primaryType": .string("Mail"), + "message": .object(["to": .string(tronRecipient)]) + ]) + + let messageSignature = try await fixture.client.signTronMessage(message: "hello") + let typedDataSignature = try await fixture.client.signTronTypedData(typedData: typedData) + + let signMessage = try fixture.transport.requestObject(for: WaasAPI.SignMessage.urlPath) + let signTypedData = try fixture.transport.requestObject(for: WaasAPI.SignTypedData.urlPath) + let expectedTypedData: NSDictionary = [ + "domain": ["name": "Example", "chainId": 3_448_148_188, "verifyingContract": tronUsdt], + "types": ["Mail": [["name": "to", "type": "address"]]], + "primaryType": "Mail", + "message": ["to": tronRecipient] + ] + #expect(messageSignature == "0xtron-signature") + #expect(typedDataSignature == "0xtron-typed") + #expect(signMessage == (["network": "", "walletId": "wallet-id", "message": "hello"] as NSDictionary)) + #expect(signTypedData["network"] as? String == "") + #expect(signTypedData["walletId"] as? String == "wallet-id") + #expect(signTypedData["typedData"] as? NSDictionary == expectedTypedData) +} + +@Test func TestTronSignatureValidationUsesTronNetworkFamily() async throws { + let fixture = makeMockWalletClient() + try fixture.transport.enqueue( + IsValidMessageSignatureResponse(isValid: true), + for: WaasPublicAPI.IsValidMessageSignature.urlPath + ) + try fixture.transport.enqueue( + IsValidTypedDataSignatureResponse(isValid: false), + for: WaasPublicAPI.IsValidTypedDataSignature.urlPath + ) + + let messageValid = try await fixture.client.isValidTronMessageSignature( + walletAddress: tronWalletAddress, + message: "hello", + signature: "0xsig" + ) + let typedDataValid = try await fixture.client.isValidTronTypedDataSignature( + walletAddress: tronWalletAddress, + typedData: .object(["primaryType": .string("Mail")]), + signature: "0xsig" + ) + + let message = try fixture.transport.requestObject(for: WaasPublicAPI.IsValidMessageSignature.urlPath) + let typedData = try fixture.transport.requestObject(for: WaasPublicAPI.IsValidTypedDataSignature.urlPath) + let expectedMessage: NSDictionary = [ + "networkFamily": "tron", + "walletAddress": tronWalletAddress, + "message": "hello", + "signature": "0xsig" + ] + let expectedTypedData: NSDictionary = [ + "networkFamily": "tron", + "walletAddress": tronWalletAddress, + "typedData": ["primaryType": "Mail"], + "signature": "0xsig" + ] + #expect(messageValid) + #expect(!typedDataValid) + #expect(message == expectedMessage) + #expect(typedData == expectedTypedData) +} + +@Test(arguments: [ + (WalletType.ethereum, "0x9999999999999999999999999999999999999999"), + (WalletType.solana, "4Nd1mYQbqjVU2aR7cJNPyqW9XjHnBYvWQd7ZxYxvT6uP") +]) +func TestTronOperationsRejectNonTronWalletsBeforeAnyRequest(type: WalletType, address: String) async { + let fixture = makeActiveWalletFixture(type: type, address: address) + + await expectValidationError(.walletSendTronTransaction) { + try await fixture.client.sendTronTransaction(network: .nile, to: tronRecipient, value: "1") + } + await expectValidationError(.walletCallTronContract) { + try await fixture.client.callTronContract(network: .nile, contract: tronUsdt, method: "transfer") + } + await expectValidationError(.walletSignTronMessage) { + try await fixture.client.signTronMessage(message: "hello") + } + await expectValidationError(.walletSignTronTypedData) { + try await fixture.client.signTronTypedData(typedData: .object([:])) + } + #expect(fixture.transport.totalRequestCount == 0) +} + +@Test func TestEvmSolanaAndSmartSessionOperationsRejectTronWalletsBeforeAnyRequest() async { + // A Tron address in hex form passes an address-shape check; the guard must use the stored type. + let fixture = makeActiveWalletFixture(type: .tron, address: tronWalletHex) + + await expectValidationError(.walletSendTransaction) { + try await fixture.client.sendTransaction(network: .polygon, to: tronWalletHex, value: "1") + } + await expectValidationError(.walletCallContract) { + try await fixture.client.callContract(network: .polygon, contract: tronWalletHex, method: "transfer", args: nil) + } + await expectValidationError(.walletSignMessage) { + try await fixture.client.signMessage(network: .polygon, message: "hello") + } + await expectValidationError(.walletSignTypedData) { + try await fixture.client.signTypedData(network: .polygon, typedData: .object([:])) + } + await expectValidationError(.walletSignSolanaMessage) { + try await fixture.client.signSolanaMessage(message: "hello") + } + await expectValidationError(.walletSendSolanaTransfer) { + try await fixture.client.sendSolanaTransfer(network: .devnet, asset: "SOL", to: "recipient", amount: "1") + } + await expectValidationError(.walletAuthorizeRemoteAccess) { + try await fixture.client.authorizeRemoteAccess( + credentialId: "remote-credential", + network: .polygon, + grants: [.nativeTransfer(to: "0x2222222222222222222222222222222222222222", limit: "1")], + expiresAt: "2099-01-01T00:00:00Z" + ) + } + #expect(fixture.transport.totalRequestCount == 0) +} + +@Test func TestCreateWalletActivatesTronWalletThroughTronNetworkFamily() async throws { + let fixture = makeActiveWalletFixture(type: .ethereum, address: "0x9999999999999999999999999999999999999999") + try fixture.transport.enqueue( + CreateWalletResponse( + wallet: WaasWallet(id: "wallet-tron", networkFamily: .tron, keyOrigin: .enclave, address: tronWalletAddress) + ), + for: WaasAPI.CreateWallet.urlPath + ) + + let result = try await fixture.client.createWallet(walletType: .tron) + + let request = try fixture.transport.requestObject(for: WaasAPI.CreateWallet.urlPath) + let expected = Wallet(id: "wallet-tron", type: .tron, address: tronWalletAddress, keyOrigin: .enclave) + #expect(request["networkFamily"] as? String == "tron") + #expect(result.wallet == expected) + #expect(fixture.client.activeWallet == expected) + #expect(fixture.client.walletId == "wallet-tron") + #expect(try fixture.storedCredentials()?.wallet == expected) +} + +@Test func TestImportWalletSealsTronPrivateKeyAndActivatesTronWallet() async throws { + let importTransport = MockWaasTransport() + let fixture = makeMockWalletClient( + walletImportClient: WaasClient(baseURL: "https://wallet-import.test", transport: importTransport) + ) + fixture.client.walletId = "wallet-main" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") + fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + let recipient = P256.KeyAgreement.PrivateKey() + try importTransport.enqueue( + WaasGenerated.GetRecipientKeyResponse( + keyId: "recipient-key", + publicKey: recipient.publicKey.derRepresentation.base64EncodedString() + ), + for: WaasAPI.GetRecipientKey.urlPath + ) + try importTransport.enqueue( + WaasGenerated.ImportWalletResponse( + wallet: WaasWallet(id: "wallet-tron", networkFamily: .tron, keyOrigin: .imported, address: tronWalletAddress) + ), + for: WaasAPI.ImportWallet.urlPath + ) + + let result = try await fixture.client.importWallet( + privateKey: .tron("0x" + String(repeating: "11", count: 32)), + reference: "imported-tron" + ) + + let request = try importTransport.requestObject(for: WaasAPI.ImportWallet.urlPath) + let expected = Wallet(id: "wallet-tron", type: .tron, address: tronWalletAddress, keyOrigin: .imported) + #expect(request["networkFamily"] as? String == "tron") + #expect(request["reference"] as? String == "imported-tron") + #expect(result.wallet == expected) + #expect(fixture.client.activeWallet == expected) +} + +@Test func TestTronPrivateKeyImportUsesSecp256k1Validation() throws { + let key = String(repeating: "11", count: 32) + #expect(try WalletImportValidation.plaintext(.tron(" 0x\(key)\n")) == Data("0x\(key)".utf8)) + #expect(try WalletImportValidation.plaintext(.tron(key)) == Data(key.utf8)) + let one = Data(repeating: 0, count: 31) + Data([1]) + #expect(try WalletImportValidation.plaintext(.tronBytes(one)) == one) + + let cases: [(WalletImportPrivateKey, String)] = [ + (.tron("0x1234"), "Tron privateKey must be 32 bytes or 64 hexadecimal characters"), + (.tronBytes(Data(repeating: 1, count: 31)), "Tron privateKey must contain exactly 32 bytes"), + (.tronBytes(Data(repeating: 0, count: 32)), "Tron privateKey is outside the valid secp256k1 scalar range"), + ( + .tron("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), + "Tron privateKey is outside the valid secp256k1 scalar range" + ), + (.ethereum("0x1234"), "Ethereum privateKey must be 32 bytes or 64 hexadecimal characters") + ] + for (privateKey, message) in cases { + do { + _ = try WalletImportValidation.plaintext(privateKey) + Issue.record("Expected \(message)") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + #expect(error.localizedDescription == message) + } + } +} + +@Test func TestTronNetworksUseWaasNetworkIdentifiers() { + #expect(TronNetwork.allCases == [.mainnet, .nile]) + #expect(TronNetworks.mainnet.rawValue == "tron:mainnet") + #expect(TronNetworks.nile.rawValue == "tron:nile") + #expect(WalletType(wireValue: "tron") == .tron) + #expect(WalletType.tron.wireValue == "tron") +} diff --git a/Tests/OMSWalletTests/WaasGeneratedAliases.swift b/Tests/OMSWalletTests/WaasGeneratedAliases.swift index ac68955..4e31acb 100644 --- a/Tests/OMSWalletTests/WaasGeneratedAliases.swift +++ b/Tests/OMSWalletTests/WaasGeneratedAliases.swift @@ -38,6 +38,8 @@ typealias ListWalletsResponse = WaasGenerated.ListWalletsResponse typealias PrepareEthereumContractCallRequest = WaasGenerated.PrepareEthereumContractCallRequest typealias PrepareEthereumTransactionRequest = WaasGenerated.PrepareEthereumTransactionRequest typealias PrepareResponse = WaasGenerated.PrepareResponse +typealias PrepareTronContractCallRequest = WaasGenerated.PrepareTronContractCallRequest +typealias PrepareTronTransactionRequest = WaasGenerated.PrepareTronTransactionRequest typealias RevokeAccessRequest = WaasGenerated.RevokeAccessRequest typealias RevokeAccessResponse = WaasGenerated.RevokeAccessResponse typealias SignMessageRequest = WaasGenerated.SignMessageRequest diff --git a/docs/error-contracts.md b/docs/error-contracts.md index ac1ab24..8f2e4c2 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -63,13 +63,14 @@ should be present, and which tests own the contract. | `handleOIDCRedirectCallback` | URL or state does not match the pending redirect | `.notOIDCRedirectCallback` result | Ignore the unrelated callback; the pending redirect remains available | Absent | `MockWalletTests.swift` | | `omsWallet.wallet.startOIDCRedirectAuth`, `handleOIDCRedirectCallback` | Local OIDC redirect-state persistence or restore failure | Thrown `OMSWalletError` with `.storageError` | Retry OIDC auth after resolving the local storage issue | Absent | `PublicErrorContractsTests.swift` | | Protected wallet methods: `getIdToken`, signing and transaction methods, wallet import, `getTransactionStatus`, access authorization/usage/listing/revocation | Missing or expired local session | `.sessionMissing` or `.sessionExpired` | Authenticate again or recover local session; no remote request was made | Absent | `PublicErrorContractsTests.swift` | -| Wallet signing, transactions, import, and owner access methods | SDK-local validation or fee-selection failure | `.validationError` | Correct parameters or local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift`, `WalletImportTests.swift`, `MockWalletTests.swift` | +| Wallet signing, transactions, import, and owner access methods | SDK-local validation, wallet-family mismatch (checked against the active wallet's stored type), non-bare contract `method` name, or fee-selection failure | `.validationError` | Correct parameters, select the required wallet family, or fix local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift`, `WalletImportTests.swift`, `MockWalletTests.swift`, `TronWalletTests.swift` | +| Wallet auth, selection, creation, and import methods | Successful WaaS response with an Ethereum wallet address that is not `0x` plus 40 hex digits, or an unreadable credential `expiresAt` | `.invalidResponse` | Treat the payload as unusable; the wallet is not activated | Absent | `ActiveWalletSessionTests.swift` | | `omsWallet.wallet.getWalletImportRecipientKey`, `importWallet`, `importEncryptedWallet` | Recipient-key attestation is missing, stale, malformed, untrusted, or does not match the request/response | `.attestationVerificationFailed` with the wallet-import operation | Do not encrypt or submit key material; retry only after confirming the SDK's managed PCR0 trust policy and WaaS environment | Absent | `WalletImportTests.swift` | -| `omsWallet.wallet.isValidMessageSignature`, `isValidSolanaMessageSignature`, `isValidTypedDataSignature` | WaaS validation backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with the validation operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift` | -| `omsWallet.wallet.sendTransaction`, `callContract` | Execute request fails after prepare | `.transactionExecutionUnconfirmed`, `operation == .walletExecute`, `retryable == false`, `txnId` | Do not blindly resend the write; preserve `txnId` and upstream detail for diagnostics | Present when execute crossed a transport/upstream boundary | `PublicErrorContractsTests.swift` | -| `omsWallet.wallet.sendTransaction`, `callContract` | Submitted transaction status polling fails | `.transactionStatusLookupFailed`, `operation == .walletTransactionStatus`, `retryable == true`, `txnId` | Retry status lookup, not the original write | Present when polling crossed a transport/upstream boundary | `PublicErrorContractsTests.swift` | +| `omsWallet.wallet.isValidMessageSignature`, `isValidSolanaMessageSignature`, `isValidTypedDataSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` | WaaS validation backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with the validation operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift` | +| `omsWallet.wallet.sendTransaction`, `sendSolanaTransfer`, `sendTronTransaction`, `callContract`, `callTronContract` | Execute request fails after prepare | `.transactionExecutionUnconfirmed`, `operation == .walletExecute`, `retryable == false`, `txnId` | Do not blindly resend the write; preserve `txnId` and upstream detail for diagnostics | Present when execute crossed a transport/upstream boundary | `PublicErrorContractsTests.swift` | +| `omsWallet.wallet.sendTransaction`, `sendSolanaTransfer`, `sendTronTransaction`, `callContract`, `callTronContract` | Submitted transaction status polling fails | `.transactionStatusLookupFailed`, `operation == .walletTransactionStatus`, `retryable == true`, `txnId` | Retry status lookup, not the original write | Present when polling crossed a transport/upstream boundary | `PublicErrorContractsTests.swift` | | `omsWallet.wallet.getTransactionStatus` | Direct status lookup backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with `operation == .walletGetTransactionStatus` | Retry status lookup or surface backend status to the user | Present | `PublicErrorContractsTests.swift` | | `omsWallet.wallet.inspectRemoteCredential`, `authorizeRemoteAccess`, `getRemoteAccessSession`, `getRemoteAccessSessionUsage`, access listing and revocation | WaaS owner-access backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with access operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift`, `MockWalletTests.swift` | -| `omsWallet.indexer.getBalances`, `getTransactionHistory`, `getSolanaBalances` | IndexerGateway backend, transport, malformed JSON, or malformed payload | `.httpError`, `.requestFailed`, or `.invalidResponse` with indexer operation | Retry based on SDK code/status; log upstream detail | Present for remote/transport response failures | `PublicErrorContractsTests.swift`, `IndexerTests.swift` | -| `omsWallet.indexer.getBalances`, `getTransactionHistory`, `getSolanaBalances` | IndexerGateway non-JSON HTTP body | `.httpError` with sanitized message | Do not expose raw upstream HTML/text bodies; log normalized detail | Present, sanitized | `PublicErrorContractsTests.swift` | +| `omsWallet.indexer.getBalances`, `getTransactionHistory`, `getSolanaBalances`, `getTronBalances` | IndexerGateway backend, transport, malformed JSON, or malformed payload | `.httpError`, `.requestFailed`, or `.invalidResponse` with indexer operation | Retry based on SDK code/status; log upstream detail | Present for remote/transport response failures | `PublicErrorContractsTests.swift`, `IndexerTests.swift` (including `TestGetTronBalancesRejectsInvalidResponses` and `TestGetTronBalancesSurfacesGatewayRequestErrors`) | +| `omsWallet.indexer.getBalances`, `getTransactionHistory`, `getSolanaBalances`, `getTronBalances` | IndexerGateway non-JSON HTTP body | `.httpError` with sanitized message | Do not expose raw upstream HTML/text bodies; log normalized detail | Present, sanitized | `PublicErrorContractsTests.swift` | | Normalized `OMSWalletError` values | Error field contract | Stable public fields on SDK-produced errors | Branch on `code`; use the remaining fields for recovery and diagnostics | As normalized by the SDK | `PublicErrorContractsTests.swift` | diff --git a/scripts/api-presentation.json b/scripts/api-presentation.json index 239f764..8776482 100644 --- a/scripts/api-presentation.json +++ b/scripts/api-presentation.json @@ -14,7 +14,7 @@ "label": "Authentication and sessions", "symbols": [ { - "path": "WalletClient.walletAddress" + "path": "WalletClient.activeWallet" }, { "path": "WalletClient.walletId" @@ -173,7 +173,7 @@ "path": "OIDCRedirectAuthResult" }, { - "path": "OMSWalletSessionState" + "path": "OMSWalletSession" }, { "path": "OMSWalletSessionAuth" @@ -210,6 +210,12 @@ { "path": "WalletClient.signTypedData(network:typedData:)" }, + { + "path": "WalletClient.signTronMessage(message:)" + }, + { + "path": "WalletClient.signTronTypedData(typedData:)" + }, { "path": "WalletClient.isValidMessageSignature(network:walletAddress:message:signature:)" }, @@ -219,6 +225,12 @@ { "path": "WalletClient.isValidTypedDataSignature(network:walletAddress:typedData:signature:)" }, + { + "path": "WalletClient.isValidTronMessageSignature(walletAddress:message:signature:)" + }, + { + "path": "WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:)" + }, { "path": "WalletClient.sendTransaction(network:to:value:selectFeeOption:mode:waitForStatus:statusPolling:)" }, @@ -228,9 +240,15 @@ { "path": "WalletClient.sendSolanaTransfer(network:asset:to:amount:selectFeeOption:mode:waitForStatus:statusPolling:)" }, + { + "path": "WalletClient.sendTronTransaction(network:to:value:data:selectFeeOption:waitForStatus:statusPolling:)" + }, { "path": "WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)" }, + { + "path": "WalletClient.callTronContract(network:contract:method:args:selectFeeOption:waitForStatus:statusPolling:)" + }, { "path": "WalletClient.getTransactionStatus(txnId:)" }, @@ -317,6 +335,33 @@ { "path": "SolanaTokenProgram" }, + { + "path": "IndexerClient.getTronBalances(_:)" + }, + { + "path": "GetTronBalancesParams" + }, + { + "path": "TronBalancesResult" + }, + { + "path": "TronBalance" + }, + { + "path": "TronNativeBalance" + }, + { + "path": "TronFungibleTokenBalance" + }, + { + "path": "TronNetworkError" + }, + { + "path": "TronVerificationStatus" + }, + { + "path": "TronTokenStandard" + }, { "path": "GetBalancesParams" }, @@ -391,6 +436,12 @@ { "path": "SolanaNetworks" }, + { + "path": "TronNetwork" + }, + { + "path": "TronNetworks" + }, { "path": "JSONValue" }, diff --git a/scripts/public-api-baseline.txt b/scripts/public-api-baseline.txt index 8209d5b..02da920 100644 --- a/scripts/public-api-baseline.txt +++ b/scripts/public-api-baseline.txt @@ -45,6 +45,7 @@ conformance FeeToken Swift.Sendable conformance GetBalancesParams Swift.Sendable conformance GetSolanaBalancesParams Swift.Sendable conformance GetTransactionHistoryParams Swift.Sendable +conformance GetTronBalancesParams Swift.Sendable conformance IndexerClient Swift.Sendable conformance IndexerNetworkType Swift.Decodable conformance IndexerNetworkType Swift.Encodable @@ -106,6 +107,8 @@ conformance OMSWalletOperation Swift.Equatable conformance OMSWalletOperation Swift.Hashable conformance OMSWalletOperation Swift.RawRepresentable conformance OMSWalletOperation Swift.Sendable +conformance OMSWalletSession Swift.Equatable +conformance OMSWalletSession Swift.Sendable conformance OMSWalletSessionAuth Swift.Decodable conformance OMSWalletSessionAuth Swift.Encodable conformance OMSWalletSessionAuth Swift.Equatable @@ -113,8 +116,6 @@ conformance OMSWalletSessionAuth Swift.Sendable conformance OMSWalletSessionExpiredEvent Swift.Equatable conformance OMSWalletSessionExpiredEvent Swift.Sendable conformance OMSWalletSessionExpiredObservation Swift.Sendable -conformance OMSWalletSessionState Swift.Equatable -conformance OMSWalletSessionState Swift.Sendable conformance OMSWalletUpstreamError Swift.Equatable conformance OMSWalletUpstreamError Swift.Sendable conformance OMSWalletUpstreamService Swift.Equatable @@ -236,6 +237,38 @@ conformance TransactionStatusResponse Swift.Sendable conformance TransactionTransfer Swift.Decodable conformance TransactionTransfer Swift.Encodable conformance TransactionTransfer Swift.Sendable +conformance TronBalance Swift.Decodable +conformance TronBalance Swift.Sendable +conformance TronBalancesResult Swift.Sendable +conformance TronFungibleTokenBalance Swift.Decodable +conformance TronFungibleTokenBalance Swift.Encodable +conformance TronFungibleTokenBalance Swift.Sendable +conformance TronNativeBalance Swift.Decodable +conformance TronNativeBalance Swift.Encodable +conformance TronNativeBalance Swift.Sendable +conformance TronNetwork Swift.CaseIterable +conformance TronNetwork Swift.CustomStringConvertible +conformance TronNetwork Swift.Decodable +conformance TronNetwork Swift.Encodable +conformance TronNetwork Swift.Equatable +conformance TronNetwork Swift.Hashable +conformance TronNetwork Swift.RawRepresentable +conformance TronNetwork Swift.Sendable +conformance TronNetworkError Swift.Decodable +conformance TronNetworkError Swift.Encodable +conformance TronNetworkError Swift.Sendable +conformance TronTokenStandard Swift.Decodable +conformance TronTokenStandard Swift.Encodable +conformance TronTokenStandard Swift.Equatable +conformance TronTokenStandard Swift.Hashable +conformance TronTokenStandard Swift.RawRepresentable +conformance TronTokenStandard Swift.Sendable +conformance TronVerificationStatus Swift.Decodable +conformance TronVerificationStatus Swift.Encodable +conformance TronVerificationStatus Swift.Equatable +conformance TronVerificationStatus Swift.Hashable +conformance TronVerificationStatus Swift.RawRepresentable +conformance TronVerificationStatus Swift.Sendable conformance UnitConversionError Swift.Equatable conformance UnitConversionError Swift.Error conformance UnitConversionError Swift.Sendable @@ -305,6 +338,11 @@ symbol swift.enum TokenBalance enum TokenBalance symbol swift.enum TransactionMode enum TransactionMode symbol swift.enum TransactionStatus enum TransactionStatus symbol swift.enum TransactionStatusResolution enum TransactionStatusResolution +symbol swift.enum TronBalance enum TronBalance +symbol swift.enum TronNetwork enum TronNetwork +symbol swift.enum TronNetworks enum TronNetworks +symbol swift.enum TronTokenStandard enum TronTokenStandard +symbol swift.enum TronVerificationStatus enum TronVerificationStatus symbol swift.enum UnitConversionError enum UnitConversionError symbol swift.enum WalletImportCipherSuite enum WalletImportCipherSuite symbol swift.enum WalletImportPrivateKey enum WalletImportPrivateKey @@ -315,7 +353,7 @@ symbol swift.enum.case AccessGrant.direct(_:) case direct(WalletCredential) symbol swift.enum.case AccessGrant.remote(_:) case remote(RemoteAccessGrant) symbol swift.enum.case AccessGrantType.direct case direct symbol swift.enum.case AccessGrantType.remote case remote -symbol swift.enum.case CompleteAuthResult.walletSelected(walletAddress:wallet:wallets:credential:) case walletSelected(walletAddress: String, wallet: Wallet, wallets: [Wallet], credential: WalletCredential) +symbol swift.enum.case CompleteAuthResult.walletSelected(wallet:wallets:credential:) case walletSelected(wallet: Wallet, wallets: [Wallet], credential: WalletCredential) symbol swift.enum.case CompleteAuthResult.walletSelection(_:) case walletSelection(PendingWalletSelection) symbol swift.enum.case ContractVerificationStatus.all case all symbol swift.enum.case ContractVerificationStatus.unverified case unverified @@ -371,11 +409,13 @@ symbol swift.enum.case OMSWalletOidcSessionAuthFlow.redirect case redirect symbol swift.enum.case OMSWalletOperation.indexerGetBalances case indexerGetBalances symbol swift.enum.case OMSWalletOperation.indexerGetSolanaBalances case indexerGetSolanaBalances symbol swift.enum.case OMSWalletOperation.indexerGetTransactionHistory case indexerGetTransactionHistory +symbol swift.enum.case OMSWalletOperation.indexerGetTronBalances case indexerGetTronBalances symbol swift.enum.case OMSWalletOperation.pendingWalletSelection case pendingWalletSelection symbol swift.enum.case OMSWalletOperation.pendingWalletSelectionCreateAndSelectWallet case pendingWalletSelectionCreateAndSelectWallet symbol swift.enum.case OMSWalletOperation.pendingWalletSelectionSelectWallet case pendingWalletSelectionSelectWallet symbol swift.enum.case OMSWalletOperation.walletAuthorizeRemoteAccess case walletAuthorizeRemoteAccess symbol swift.enum.case OMSWalletOperation.walletCallContract case walletCallContract +symbol swift.enum.case OMSWalletOperation.walletCallTronContract case walletCallTronContract symbol swift.enum.case OMSWalletOperation.walletCompleteEmailAuth case walletCompleteEmailAuth symbol swift.enum.case OMSWalletOperation.walletCreateWallet case walletCreateWallet symbol swift.enum.case OMSWalletOperation.walletExecute case walletExecute @@ -390,6 +430,8 @@ symbol swift.enum.case OMSWalletOperation.walletImportWallet case walletImportWa symbol swift.enum.case OMSWalletOperation.walletInspectRemoteCredential case walletInspectRemoteCredential symbol swift.enum.case OMSWalletOperation.walletIsValidMessageSignature case walletIsValidMessageSignature symbol swift.enum.case OMSWalletOperation.walletIsValidSolanaMessageSignature case walletIsValidSolanaMessageSignature +symbol swift.enum.case OMSWalletOperation.walletIsValidTronMessageSignature case walletIsValidTronMessageSignature +symbol swift.enum.case OMSWalletOperation.walletIsValidTronTypedDataSignature case walletIsValidTronTypedDataSignature symbol swift.enum.case OMSWalletOperation.walletIsValidTypedDataSignature case walletIsValidTypedDataSignature symbol swift.enum.case OMSWalletOperation.walletListAccess case walletListAccess symbol swift.enum.case OMSWalletOperation.walletListAccessPage case walletListAccessPage @@ -398,10 +440,13 @@ symbol swift.enum.case OMSWalletOperation.walletListWallets case walletListWalle symbol swift.enum.case OMSWalletOperation.walletRevokeAccess case walletRevokeAccess symbol swift.enum.case OMSWalletOperation.walletSendSolanaTransfer case walletSendSolanaTransfer symbol swift.enum.case OMSWalletOperation.walletSendTransaction case walletSendTransaction +symbol swift.enum.case OMSWalletOperation.walletSendTronTransaction case walletSendTronTransaction symbol swift.enum.case OMSWalletOperation.walletSignInWithOidcIdToken case walletSignInWithOidcIdToken symbol swift.enum.case OMSWalletOperation.walletSignMessage case walletSignMessage symbol swift.enum.case OMSWalletOperation.walletSignOut case walletSignOut symbol swift.enum.case OMSWalletOperation.walletSignSolanaMessage case walletSignSolanaMessage +symbol swift.enum.case OMSWalletOperation.walletSignTronMessage case walletSignTronMessage +symbol swift.enum.case OMSWalletOperation.walletSignTronTypedData case walletSignTronTypedData symbol swift.enum.case OMSWalletOperation.walletSignTypedData case walletSignTypedData symbol swift.enum.case OMSWalletOperation.walletStartEmailAuth case walletStartEmailAuth symbol swift.enum.case OMSWalletOperation.walletStartOIDCRedirectAuth case walletStartOIDCRedirectAuth @@ -440,6 +485,14 @@ symbol swift.enum.case TransactionStatus.unknown(_:) case unknown(String) symbol swift.enum.case TransactionStatusResolution.notRequested case notRequested symbol swift.enum.case TransactionStatusResolution.resolved case resolved symbol swift.enum.case TransactionStatusResolution.timedOut case timedOut +symbol swift.enum.case TronBalance.fungibleToken(_:) case fungibleToken(TronFungibleTokenBalance) +symbol swift.enum.case TronBalance.native(_:) case native(TronNativeBalance) +symbol swift.enum.case TronNetwork.mainnet case mainnet +symbol swift.enum.case TronNetwork.nile case nile +symbol swift.enum.case TronTokenStandard.trc20 case trc20 +symbol swift.enum.case TronVerificationStatus.unknown case unknown +symbol swift.enum.case TronVerificationStatus.unverified case unverified +symbol swift.enum.case TronVerificationStatus.verified case verified symbol swift.enum.case UnitConversionError.fractionalComponentExceedsDecimals(value:decimals:) case fractionalComponentExceedsDecimals(value: String, decimals: Int) symbol swift.enum.case UnitConversionError.invalidDecimals(_:) case invalidDecimals(Int) symbol swift.enum.case UnitConversionError.invalidValue(_:) case invalidValue(String) @@ -451,6 +504,8 @@ symbol swift.enum.case WalletImportPrivateKey.ethereum(_:) case ethereum(String) symbol swift.enum.case WalletImportPrivateKey.ethereumBytes(_:) case ethereumBytes(Data) symbol swift.enum.case WalletImportPrivateKey.solana(_:) case solana(String) symbol swift.enum.case WalletImportPrivateKey.solanaBytes(_:) case solanaBytes(Data) +symbol swift.enum.case WalletImportPrivateKey.tron(_:) case tron(String) +symbol swift.enum.case WalletImportPrivateKey.tronBytes(_:) case tronBytes(Data) symbol swift.enum.case WalletKeyOrigin.enclave case enclave symbol swift.enum.case WalletKeyOrigin.imported case imported symbol swift.enum.case WalletKeyOrigin.unknown(_:) case unknown(String) @@ -458,6 +513,7 @@ symbol swift.enum.case WalletSelectionBehavior.automatic case automatic symbol swift.enum.case WalletSelectionBehavior.manual case manual symbol swift.enum.case WalletType.ethereum case ethereum symbol swift.enum.case WalletType.solana case solana +symbol swift.enum.case WalletType.tron case tron symbol swift.enum.case WalletType.unknown(_:) case unknown(String) symbol swift.func formatUnits(value:decimals:) func formatUnits(value: String, decimals: Int = 18) throws -> String symbol swift.func parseUnits(value:decimals:) func parseUnits(value: String, decimals: Int = 18) throws -> String @@ -483,6 +539,7 @@ symbol swift.init FeeToken.init(network:name:symbol:type:decimals:logoUrl:contra symbol swift.init GetBalancesParams.init(walletAddress:networks:networkType:contractAddresses:includeMetadata:omitPrices:tokenIds:contractStatus:page:) init(walletAddress: String, networks: [Network]? = nil, networkType: IndexerNetworkType? = nil, contractAddresses: [String]? = nil, includeMetadata: Bool = true, omitPrices: Bool? = nil, tokenIds: [String]? = nil, contractStatus: ContractVerificationStatus? = nil, page: TokenBalancesPageRequest? = nil) symbol swift.init GetSolanaBalancesParams.init(walletAddress:networks:includeMetadata:omitNativeBalances:mintAddresses:excludedMintAddresses:) init(walletAddress: String, networks: [SolanaNetwork] = [.mainnet, .devnet], includeMetadata: Bool = true, omitNativeBalances: Bool? = nil, mintAddresses: [String] = [], excludedMintAddresses: [String] = []) symbol swift.init GetTransactionHistoryParams.init(walletAddress:networks:networkType:contractAddresses:transactionHashes:metaTransactionIds:fromBlock:toBlock:tokenId:includeMetadata:omitPrices:metadataOptions:page:) init(walletAddress: String, networks: [Network]? = nil, networkType: IndexerNetworkType? = nil, contractAddresses: [String]? = nil, transactionHashes: [String]? = nil, metaTransactionIds: [String]? = nil, fromBlock: Int? = nil, toBlock: Int? = nil, tokenId: String? = nil, includeMetadata: Bool = true, omitPrices: Bool? = nil, metadataOptions: MetadataOptions? = nil, page: TokenBalancesPageRequest? = nil) +symbol swift.init GetTronBalancesParams.init(walletAddress:networks:includeMetadata:omitNativeBalances:contractAddresses:excludedContractAddresses:) init(walletAddress: String, networks: [TronNetwork] = [.mainnet, .nile], includeMetadata: Bool = true, omitNativeBalances: Bool? = nil, contractAddresses: [String] = [], excludedContractAddresses: [String] = []) symbol swift.init IndexerNetworkType.init(rawValue:) init?(rawValue: String) symbol swift.init JSONValue.init(from:) init(from decoder: Decoder) throws symbol swift.init MetadataOptions.init(from:) init(from decoder: any Decoder) throws @@ -499,9 +556,9 @@ symbol swift.init OMSWalletOidcSessionAuth.init(flow:issuer:provider:providerLab symbol swift.init OMSWalletOidcSessionAuth.init(from:) init(from decoder: Decoder) throws symbol swift.init OMSWalletOidcSessionAuthFlow.init(rawValue:) init?(rawValue: String) symbol swift.init OMSWalletOperation.init(rawValue:) init?(rawValue: String) +symbol swift.init OMSWalletSession.init(expiresAt:auth:) init(expiresAt: Date, auth: OMSWalletSessionAuth) symbol swift.init OMSWalletSessionAuth.init(from:) init(from decoder: Decoder) throws -symbol swift.init OMSWalletSessionExpiredEvent.init(session:expiredAt:) init(session: OMSWalletSessionState, expiredAt: Date) -symbol swift.init OMSWalletSessionState.init(walletAddress:expiresAt:auth:) init(walletAddress: String?, expiresAt: Date? = nil, auth: OMSWalletSessionAuth? = nil) +symbol swift.init OMSWalletSessionExpiredEvent.init(wallet:session:expiredAt:) init(wallet: Wallet?, session: OMSWalletSession, expiredAt: Date) symbol swift.init OMSWalletUpstreamService.init(rawValue:) init?(rawValue: String) symbol swift.init Page.init(from:) init(from decoder: any Decoder) throws symbol swift.init Page.init(limit:cursor:) init(limit: UInt32? = nil, cursor: String? = nil) @@ -546,6 +603,13 @@ symbol swift.init TransactionStatusResponse.init(from:) init(from decoder: any D symbol swift.init TransactionStatusResponse.init(status:txnHash:) init(status: TransactionStatus, txnHash: String? = nil) symbol swift.init TransactionTransfer.init(from:) init(from decoder: Decoder) throws symbol swift.init TransactionTransfer.init(transferType:contractAddress:contractType:from:to:tokenIds:amounts:logIndex:amountsUSD:pricesUSD:contractInfo:tokenMetadata:) init(transferType: String, contractAddress: String, contractType: String, from: String, to: String, tokenIds: [String]? = nil, amounts: [String], logIndex: Int, amountsUSD: [String]? = nil, pricesUSD: [String]? = nil, contractInfo: TokenContractInfo? = nil, tokenMetadata: [String : TokenMetadata]? = nil) +symbol swift.init TronBalance.init(from:) init(from decoder: Decoder) throws +symbol swift.init TronFungibleTokenBalance.init(from:) init(from decoder: Decoder) throws +symbol swift.init TronNativeBalance.init(from:) init(from decoder: Decoder) throws +symbol swift.init TronNetwork.init(rawValue:) init?(rawValue: String) +symbol swift.init TronNetworkError.init(from:) init(from decoder: any Decoder) throws +symbol swift.init TronTokenStandard.init(rawValue:) init?(rawValue: String) +symbol swift.init TronVerificationStatus.init(rawValue:) init?(rawValue: String) symbol swift.init Wallet.init(from:) init(from decoder: any Decoder) throws symbol swift.init Wallet.init(id:type:address:reference:keyOrigin:) init(id: String, type: WalletType, address: String, reference: String? = nil, keyOrigin: WalletKeyOrigin) symbol swift.init WalletCredential.init(credentialId:expiresAt:isCaller:) init(credentialId: String, expiresAt: String, isCaller: Bool) @@ -555,7 +619,7 @@ symbol swift.init WalletImportRecipientKey.init(keyId:cipherSuite:publicKey:) in symbol swift.init WalletKeyOrigin.init(from:) init(from decoder: Decoder) throws symbol swift.init WalletKeyOrigin.init(wireValue:) init(wireValue: String) symbol swift.init WalletSelectionBehavior.init(rawValue:) init?(rawValue: String) -symbol swift.init WalletSelectionResult.init(walletAddress:wallet:) init(walletAddress: String, wallet: Wallet) +symbol swift.init WalletSelectionResult.init(wallet:) init(wallet: Wallet) symbol swift.init WalletType.init(from:) init(from decoder: Decoder) throws symbol swift.init WalletType.init(wireValue:) init(wireValue: String) symbol swift.method ContractTokenBalance.encode(to:) func encode(to encoder: Encoder) throws @@ -564,6 +628,7 @@ symbol swift.method FeeOptionSelector.callAsFunction(_:) func callAsFunction(_ o symbol swift.method IndexerClient.getBalances(_:) func getBalances(_ params: GetBalancesParams) async throws -> BalancesResult symbol swift.method IndexerClient.getSolanaBalances(_:) func getSolanaBalances(_ params: GetSolanaBalancesParams) async throws -> SolanaBalancesResult symbol swift.method IndexerClient.getTransactionHistory(_:) func getTransactionHistory(_ params: GetTransactionHistoryParams) async throws -> TransactionHistoryResult +symbol swift.method IndexerClient.getTronBalances(_:) func getTronBalances(_ params: GetTronBalancesParams) async throws -> TronBalancesResult symbol swift.method JSONValue.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method ListAccessPages.AsyncIterator.next() mutating func next() async throws -> AccessGrantPage? symbol swift.method ListAccessPages.makeAsyncIterator() func makeAsyncIterator() -> AsyncIterator @@ -583,6 +648,7 @@ symbol swift.method TransactionTransfer.encode(to:) func encode(to encoder: Enco symbol swift.method WalletClient.addSessionExpiredObserver(_:) @discardableResult func addSessionExpiredObserver(_ observer: @escaping @MainActor @Sendable (OMSWalletSessionExpiredEvent) -> Void) -> OMSWalletSessionExpiredObservation symbol swift.method WalletClient.authorizeRemoteAccess(credentialId:network:grants:expiresAt:sessionId:) func authorizeRemoteAccess(credentialId: String, network: Network, grants: [SmartSessionGrant], expiresAt: String, sessionId: String? = nil) async throws -> AuthorizedRemoteAccess symbol swift.method WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:) func callContract(network: Network, contract: String, method: String, args: [AbiArg]?, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +symbol swift.method WalletClient.callTronContract(network:contract:method:args:selectFeeOption:waitForStatus:statusPolling:) func callTronContract(network: TronNetwork, contract: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.completeEmailAuth(code:walletSelection:walletType:) @discardableResult func completeEmailAuth(code: String, walletSelection: WalletSelectionBehavior = .automatic, walletType: WalletType = WalletType.ethereum) async throws -> CompleteAuthResult symbol swift.method WalletClient.createWallet(walletType:reference:) @discardableResult func createWallet(walletType: WalletType = WalletType.ethereum, reference: String? = nil) async throws -> WalletSelectionResult symbol swift.method WalletClient.getIdToken(ttlSeconds:customClaims:) func getIdToken(ttlSeconds: UInt32? = nil, customClaims: [String : JSONValue]? = nil) async throws -> String @@ -596,6 +662,8 @@ symbol swift.method WalletClient.importWallet(privateKey:reference:) @discardabl symbol swift.method WalletClient.inspectRemoteCredential(credentialId:) func inspectRemoteCredential(credentialId: String) async throws -> RemoteCredentialMetadata symbol swift.method WalletClient.isValidMessageSignature(network:walletAddress:message:signature:) func isValidMessageSignature(network: Network, walletAddress: String, message: String, signature: String) async throws -> Bool symbol swift.method WalletClient.isValidSolanaMessageSignature(walletAddress:message:signature:) func isValidSolanaMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidTronMessageSignature(walletAddress:message:signature:) func isValidTronMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:) func isValidTronTypedDataSignature(walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool symbol swift.method WalletClient.isValidTypedDataSignature(network:walletAddress:typedData:signature:) func isValidTypedDataSignature(network: Network, walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool symbol swift.method WalletClient.listAccess(pageSize:type:) func listAccess(pageSize: UInt32? = nil, type: AccessGrantType? = nil) async throws -> [AccessGrant] symbol swift.method WalletClient.listAccessPage(pageSize:cursor:type:) func listAccessPage(pageSize: UInt32? = nil, cursor: String? = nil, type: AccessGrantType? = nil) async throws -> AccessGrantPage @@ -605,10 +673,13 @@ symbol swift.method WalletClient.revokeAccess(credentialId:sessionId:) func revo symbol swift.method WalletClient.sendSolanaTransfer(network:asset:to:amount:selectFeeOption:mode:waitForStatus:statusPolling:) func sendSolanaTransfer(network: SolanaNetwork, asset: String, to: String, amount: String, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.sendTransaction(network:request:selectFeeOption:waitForStatus:statusPolling:) func sendTransaction(network: Network, request: SendTransactionRequest, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.sendTransaction(network:to:value:selectFeeOption:mode:waitForStatus:statusPolling:) func sendTransaction(network: Network, to: String, value: String, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +symbol swift.method WalletClient.sendTronTransaction(network:to:value:data:selectFeeOption:waitForStatus:statusPolling:) func sendTronTransaction(network: TronNetwork, to: String, value: String = "0", data: String? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.signInWithOidcIdToken(idToken:issuer:audience:walletType:walletSelection:sessionLifetimeSeconds:provider:providerLabel:) @discardableResult func signInWithOidcIdToken(idToken: String, issuer: String, audience: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior = .automatic, sessionLifetimeSeconds: UInt32 = 604_800, provider: String? = nil, providerLabel: String? = nil) async throws -> CompleteAuthResult symbol swift.method WalletClient.signMessage(network:message:) func signMessage(network: Network, message: String) async throws -> String symbol swift.method WalletClient.signOut() func signOut() throws symbol swift.method WalletClient.signSolanaMessage(message:) func signSolanaMessage(message: String) async throws -> String +symbol swift.method WalletClient.signTronMessage(message:) func signTronMessage(message: String) async throws -> String +symbol swift.method WalletClient.signTronTypedData(typedData:) func signTronTypedData(typedData: JSONValue) async throws -> String symbol swift.method WalletClient.signTypedData(network:typedData:) func signTypedData(network: Network, typedData: JSONValue) async throws -> String symbol swift.method WalletClient.startEmailAuth(email:sessionLifetimeSeconds:) func startEmailAuth(email: String, sessionLifetimeSeconds: UInt32 = 604_800) async throws symbol swift.method WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:loginHint:walletSelection:sessionLifetimeSeconds:) func startOIDCRedirectAuth(provider: OMSRelayOIDCProvider, omsRelayReturnURI: String, walletType: WalletType = WalletType.ethereum, loginHint: String? = nil, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil) async throws -> StartOIDCRedirectAuthResult @@ -630,7 +701,6 @@ symbol swift.property BalancesResult.page let page: TokenBalancesPage? symbol swift.property BalancesResult.status let status: Int symbol swift.property CompleteAuthResult.credential var credential: WalletCredential { get } symbol swift.property CompleteAuthResult.wallet var wallet: Wallet? { get } -symbol swift.property CompleteAuthResult.walletAddress var walletAddress: String? { get } symbol swift.property ContractTokenBalance.accountAddress let accountAddress: String symbol swift.property ContractTokenBalance.balance let balance: String symbol swift.property ContractTokenBalance.balanceUSD let balanceUSD: String? @@ -706,6 +776,12 @@ symbol swift.property GetTransactionHistoryParams.toBlock let toBlock: Int? symbol swift.property GetTransactionHistoryParams.tokenId let tokenId: String? symbol swift.property GetTransactionHistoryParams.transactionHashes let transactionHashes: [String]? symbol swift.property GetTransactionHistoryParams.walletAddress let walletAddress: String +symbol swift.property GetTronBalancesParams.contractAddresses let contractAddresses: [String] +symbol swift.property GetTronBalancesParams.excludedContractAddresses let excludedContractAddresses: [String] +symbol swift.property GetTronBalancesParams.includeMetadata let includeMetadata: Bool +symbol swift.property GetTronBalancesParams.networks let networks: [TronNetwork] +symbol swift.property GetTronBalancesParams.omitNativeBalances let omitNativeBalances: Bool? +symbol swift.property GetTronBalancesParams.walletAddress let walletAddress: String symbol swift.property MetadataOptions.includeContracts let includeContracts: [String]? symbol swift.property MetadataOptions.unverifiedOnly let unverifiedOnly: Bool? symbol swift.property MetadataOptions.verifiedOnly let verifiedOnly: Bool? @@ -742,12 +818,12 @@ symbol swift.property OMSWalletOidcSessionAuth.flow let flow: OMSWalletOidcSessi symbol swift.property OMSWalletOidcSessionAuth.issuer let issuer: String symbol swift.property OMSWalletOidcSessionAuth.provider let provider: String? symbol swift.property OMSWalletOidcSessionAuth.providerLabel let providerLabel: String? +symbol swift.property OMSWalletSession.auth let auth: OMSWalletSessionAuth +symbol swift.property OMSWalletSession.expiresAt let expiresAt: Date symbol swift.property OMSWalletSessionAuth.email var email: String? { get } symbol swift.property OMSWalletSessionExpiredEvent.expiredAt let expiredAt: Date -symbol swift.property OMSWalletSessionExpiredEvent.session let session: OMSWalletSessionState -symbol swift.property OMSWalletSessionState.auth let auth: OMSWalletSessionAuth? -symbol swift.property OMSWalletSessionState.expiresAt let expiresAt: Date? -symbol swift.property OMSWalletSessionState.walletAddress let walletAddress: String? +symbol swift.property OMSWalletSessionExpiredEvent.session let session: OMSWalletSession +symbol swift.property OMSWalletSessionExpiredEvent.wallet let wallet: Wallet? symbol swift.property OMSWalletUpstreamError.code let code: String? symbol swift.property OMSWalletUpstreamError.message let message: String? symbol swift.property OMSWalletUpstreamError.name let name: String? @@ -915,13 +991,47 @@ symbol swift.property TransactionTransfer.to let to: String symbol swift.property TransactionTransfer.tokenIds let tokenIds: [String]? symbol swift.property TransactionTransfer.tokenMetadata let tokenMetadata: [String : TokenMetadata]? symbol swift.property TransactionTransfer.transferType let transferType: String +symbol swift.property TronBalancesResult.balances let balances: [TronBalance] +symbol swift.property TronBalancesResult.errors let errors: [TronNetworkError] +symbol swift.property TronBalancesResult.status let status: Int +symbol swift.property TronFungibleTokenBalance.accountAddress let accountAddress: String +symbol swift.property TronFungibleTokenBalance.balance let balance: String +symbol swift.property TronFungibleTokenBalance.balanceUSD let balanceUSD: String? +symbol swift.property TronFungibleTokenBalance.contractAddress let contractAddress: String +symbol swift.property TronFungibleTokenBalance.decimals let decimals: Int +symbol swift.property TronFungibleTokenBalance.formattedBalance let formattedBalance: String +symbol swift.property TronFungibleTokenBalance.imageUrl let imageUrl: String? +symbol swift.property TronFungibleTokenBalance.metadataUri let metadataUri: String? +symbol swift.property TronFungibleTokenBalance.name let name: String +symbol swift.property TronFungibleTokenBalance.network let network: TronNetwork +symbol swift.property TronFungibleTokenBalance.priceUSD let priceUSD: String? +symbol swift.property TronFungibleTokenBalance.symbol let symbol: String +symbol swift.property TronFungibleTokenBalance.tokenStandard let tokenStandard: TronTokenStandard +symbol swift.property TronFungibleTokenBalance.verificationSource let verificationSource: String +symbol swift.property TronFungibleTokenBalance.verificationStatus let verificationStatus: TronVerificationStatus +symbol swift.property TronNativeBalance.accountAddress let accountAddress: String +symbol swift.property TronNativeBalance.balance let balance: String +symbol swift.property TronNativeBalance.balanceUSD let balanceUSD: String? +symbol swift.property TronNativeBalance.decimals let decimals: Int +symbol swift.property TronNativeBalance.formattedBalance let formattedBalance: String +symbol swift.property TronNativeBalance.imageUrl let imageUrl: String? +symbol swift.property TronNativeBalance.metadataUri let metadataUri: String? +symbol swift.property TronNativeBalance.name let name: String +symbol swift.property TronNativeBalance.network let network: TronNetwork +symbol swift.property TronNativeBalance.priceUSD let priceUSD: String? +symbol swift.property TronNativeBalance.symbol let symbol: String +symbol swift.property TronNativeBalance.verificationSource let verificationSource: String +symbol swift.property TronNativeBalance.verificationStatus let verificationStatus: TronVerificationStatus +symbol swift.property TronNetwork.description var description: String { get } +symbol swift.property TronNetworkError.network let network: TronNetwork +symbol swift.property TronNetworkError.reason let reason: String symbol swift.property Wallet.address let address: String symbol swift.property Wallet.id let id: String symbol swift.property Wallet.keyOrigin let keyOrigin: WalletKeyOrigin symbol swift.property Wallet.reference let reference: String? symbol swift.property Wallet.type let type: WalletType -symbol swift.property WalletClient.session var session: OMSWalletSessionState { get } -symbol swift.property WalletClient.walletAddress var walletAddress: String? { get set } +symbol swift.property WalletClient.activeWallet var activeWallet: Wallet? { get set } +symbol swift.property WalletClient.session var session: OMSWalletSession? { get } symbol swift.property WalletClient.walletId var walletId: String { get set } symbol swift.property WalletCredential.credentialId let credentialId: String symbol swift.property WalletCredential.expiresAt let expiresAt: String @@ -931,7 +1041,6 @@ symbol swift.property WalletImportRecipientKey.keyId let keyId: String symbol swift.property WalletImportRecipientKey.publicKey let publicKey: String symbol swift.property WalletKeyOrigin.wireValue var wireValue: String { get } symbol swift.property WalletSelectionResult.wallet let wallet: Wallet -symbol swift.property WalletSelectionResult.walletAddress let walletAddress: String symbol swift.property WalletType.wireValue var wireValue: String { get } symbol swift.struct AbiArg struct AbiArg symbol swift.struct AccessGrantPage struct AccessGrantPage @@ -948,6 +1057,7 @@ symbol swift.struct FeeToken struct FeeToken symbol swift.struct GetBalancesParams struct GetBalancesParams symbol swift.struct GetSolanaBalancesParams struct GetSolanaBalancesParams symbol swift.struct GetTransactionHistoryParams struct GetTransactionHistoryParams +symbol swift.struct GetTronBalancesParams struct GetTronBalancesParams symbol swift.struct ListAccessPages struct ListAccessPages symbol swift.struct ListAccessPages.AsyncIterator struct AsyncIterator symbol swift.struct MetadataOptions struct MetadataOptions @@ -956,8 +1066,8 @@ symbol swift.struct OMSRelayOIDCProvider struct OMSRelayOIDCProvider symbol swift.struct OMSWalletEmailSessionAuth struct OMSWalletEmailSessionAuth symbol swift.struct OMSWalletError struct OMSWalletError symbol swift.struct OMSWalletOidcSessionAuth struct OMSWalletOidcSessionAuth +symbol swift.struct OMSWalletSession struct OMSWalletSession symbol swift.struct OMSWalletSessionExpiredEvent struct OMSWalletSessionExpiredEvent -symbol swift.struct OMSWalletSessionState struct OMSWalletSessionState symbol swift.struct OMSWalletUpstreamError struct OMSWalletUpstreamError symbol swift.struct Page struct Page symbol swift.struct RemoteAccessGrant struct RemoteAccessGrant @@ -982,6 +1092,10 @@ symbol swift.struct TransactionHistoryResult struct TransactionHistoryResult symbol swift.struct TransactionStatusPollingOptions struct TransactionStatusPollingOptions symbol swift.struct TransactionStatusResponse struct TransactionStatusResponse symbol swift.struct TransactionTransfer struct TransactionTransfer +symbol swift.struct TronBalancesResult struct TronBalancesResult +symbol swift.struct TronFungibleTokenBalance struct TronFungibleTokenBalance +symbol swift.struct TronNativeBalance struct TronNativeBalance +symbol swift.struct TronNetworkError struct TronNetworkError symbol swift.struct Wallet struct Wallet symbol swift.struct WalletCredential struct WalletCredential symbol swift.struct WalletImportRecipientKey struct WalletImportRecipientKey @@ -996,5 +1110,7 @@ symbol swift.type.property OMSRelayOIDCProviders.apple static let apple: OMSRela symbol swift.type.property OMSRelayOIDCProviders.google static let google: OMSRelayOIDCProvider symbol swift.type.property SolanaNetworks.devnet static let devnet: SolanaNetwork symbol swift.type.property SolanaNetworks.mainnet static let mainnet: SolanaNetwork +symbol swift.type.property TronNetworks.mainnet static let mainnet: TronNetwork +symbol swift.type.property TronNetworks.nile static let nile: TronNetwork symbol swift.typealias FeeOptionSelector.Select typealias Select = @Sendable (_ options: [FeeOptionWithBalance]) async throws -> FeeOptionSelection? symbol swift.typealias ListAccessPages.Element typealias Element = AccessGrantPage From 89d2a9e57e5b656ef7e38df7db1d15f0ed8c710f Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 14:02:42 +0300 Subject: [PATCH 02/35] test: verify wallet-import HPKE against CryptoKit and RFC 9180 vectors - Open SDK-sealed payloads with CryptoKit's HPKE.Recipient (macOS 14+/iOS 17+), including a tampered-ciphertext rejection. - Check the hand-written DHKEM(P-256, HKDF-SHA256)/HKDF-SHA256/AES-256-GCM base mode against the official CFRG test vector referenced by RFC 9180: enc, shared secret, key schedule context, secret, key, base nonce, and ciphertext. - Add an internal P256HPKE hook that takes the ephemeral key, info, and aad so the vector can be reproduced; the public seal path is unchanged. --- Sources/OMSWallet/WalletImport.swift | 64 ++++++++-- .../WalletImportHPKETests.swift | 112 ++++++++++++++++++ 2 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 Tests/OMSWalletTests/WalletImportHPKETests.swift diff --git a/Sources/OMSWallet/WalletImport.swift b/Sources/OMSWallet/WalletImport.swift index 25d23fc..80543f0 100644 --- a/Sources/OMSWallet/WalletImport.swift +++ b/Sources/OMSWallet/WalletImport.swift @@ -176,11 +176,54 @@ enum WalletImportValidation { } enum P256HPKE { + /// RFC 9180 base-mode sender context for DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-256-GCM. + struct SenderContext { + let encapsulatedKey: Data + let sharedSecret: Data + let keyScheduleContext: Data + let secret: Data + let key: Data + let baseNonce: Data + } + static func seal(recipientPublicKey: Data, plaintext: Data) throws -> (encapsulatedKey: Data, ciphertext: Data) { + try seal( + recipientPublicKey: recipientPublicKey, + plaintext: plaintext, + ephemeralPrivateKey: P256.KeyAgreement.PrivateKey() + ) + } + + // Internal so tests can supply the ephemeral key, info, and aad from RFC 9180 test vectors. + static func seal( + recipientPublicKey: Data, + plaintext: Data, + ephemeralPrivateKey: P256.KeyAgreement.PrivateKey, + info: Data = Data(), + aad: Data = Data() + ) throws -> (encapsulatedKey: Data, ciphertext: Data) { + let context = try setupBaseSender( + recipientPublicKey: recipientPublicKey, + ephemeralPrivateKey: ephemeralPrivateKey, + info: info + ) + let sealed = try AES.GCM.seal( + plaintext, + using: SymmetricKey(data: context.key), + nonce: AES.GCM.Nonce(data: context.baseNonce), + authenticating: aad + ) + return (context.encapsulatedKey, sealed.ciphertext + sealed.tag) + } + + static func setupBaseSender( + recipientPublicKey: Data, + ephemeralPrivateKey: P256.KeyAgreement.PrivateKey, + info: Data + ) throws -> SenderContext { let recipient = try P256.KeyAgreement.PublicKey(derRepresentation: recipientPublicKey) - let ephemeral = P256.KeyAgreement.PrivateKey() - let encapsulatedKey = ephemeral.publicKey.x963Representation - let sharedSecret = try ephemeral.sharedSecretFromKeyAgreement(with: recipient) + let encapsulatedKey = ephemeralPrivateKey.publicKey.x963Representation + let sharedSecret = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: recipient) let dh = sharedSecret.withUnsafeBytes { Data($0) } let kemSuiteId = Data("KEM".utf8) + uint16(0x0010) @@ -190,19 +233,20 @@ enum P256HPKE { let suiteId = Data("HPKE".utf8) + uint16(0x0010) + uint16(0x0001) + uint16(0x0002) let pskIdHash = labeledExtract(salt: Data(), suiteId: suiteId, label: "psk_id_hash", ikm: Data()) - let infoHash = labeledExtract(salt: Data(), suiteId: suiteId, label: "info_hash", ikm: Data()) + let infoHash = labeledExtract(salt: Data(), suiteId: suiteId, label: "info_hash", ikm: info) let context = Data([0]) + pskIdHash + infoHash let secret = labeledExtract(salt: shared, suiteId: suiteId, label: "secret", ikm: Data()) let key = try labeledExpand(prk: secret, suiteId: suiteId, label: "key", info: context, length: 32) let nonce = try labeledExpand(prk: secret, suiteId: suiteId, label: "base_nonce", info: context, length: 12) - let sealed = try AES.GCM.seal( - plaintext, - using: SymmetricKey(data: key), - nonce: AES.GCM.Nonce(data: nonce), - authenticating: Data() + return SenderContext( + encapsulatedKey: encapsulatedKey, + sharedSecret: shared, + keyScheduleContext: context, + secret: secret, + key: key, + baseNonce: nonce ) - return (encapsulatedKey, sealed.ciphertext + sealed.tag) } private static func labeledExtract(salt: Data, suiteId: Data, label: String, ikm: Data) -> Data { diff --git a/Tests/OMSWalletTests/WalletImportHPKETests.swift b/Tests/OMSWalletTests/WalletImportHPKETests.swift new file mode 100644 index 0000000..4cd1807 --- /dev/null +++ b/Tests/OMSWalletTests/WalletImportHPKETests.swift @@ -0,0 +1,112 @@ +import CryptoKit +import Foundation +import Testing +@testable import OMSWallet + +// RFC 9180 test vector for mode_base, DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-256-GCM +// (kem_id 0x0010, kdf_id 0x0001, aead_id 0x0002), from the official CFRG test vectors referenced +// by RFC 9180: https://github.com/cfrg/draft-irtf-cfrg-hpke/blob/master/test-vectors.json +private enum RFC9180P256Sha256Aes256GcmBase { + static let info = "4f6465206f6e2061204772656369616e2055726e" + static let skRm = "317f915db7bc629c48fe765587897e01e282d3e8445f79f27f65d031a88082b2" + static let skEm = "90345e3a1d116c1dd39ae76d95ab858c142223a63e44f8f85318cfa91a84858e" + static let pkRm = "04abc7e49a4c6b3566d77d0304addc6ed0e98512ffccf505e6a8e3eb25c685136f853148544876de76c0f2ef99cdc3a05ccf5ded7860c7c021238f9e2073d2356c" + static let pkEm = "04c06b4f6bebc7bb495cb797ab753f911aff80aefb86fd8b6fcc35525f3ab5f03e0b21bd31a86c6048af3cb2d98e0d3bf01da5cc4c39ff5370d331a4f1f7d5a4e0" + static let enc = "04c06b4f6bebc7bb495cb797ab753f911aff80aefb86fd8b6fcc35525f3ab5f03e0b21bd31a86c6048af3cb2d98e0d3bf01da5cc4c39ff5370d331a4f1f7d5a4e0" + static let sharedSecret = "48893fecd82f7c3456af6a42d8f56325d21e08c10fa81299986aaff54cde7b49" + static let keyScheduleContext = "008fc3aeb832490a4b5ab3e42023287db29a1f4bc7c222c0df228727b70a4021127f1ff3fd1aa97af7e5d473e1cb01ba74831133d9659b6c26b03a038a49a84074" + static let secret = "520da82c752ee6e0be7aafbad57a62535d266b6333513d3eb94cb497dceaf94e" + static let key = "ee16802a936d5f544771131900ee6973d0551de9e852ece2ef34bf0d5f9e1d1d" + static let baseNonce = "9bc50980832a7b4b58c40161" + + // encryptions[0]: sequence number 0, so the nonce is base_nonce. + static let aad = "436f756e742d30" + static let plaintext = "4265617574792069732074727574682c20747275746820626561757479" + static let ciphertext = "58c61a45059d0c5704560e9d88b564a8b63f1364b8d1fcb3c4c6ddc1d291742465e902cd216f8908da49f8f96f" +} + +private func hex(_ value: String) throws -> Data { + var bytes = [UInt8]() + var index = value.startIndex + while index < value.endIndex { + let next = value.index(index, offsetBy: 2) + bytes.append(try #require(UInt8(value[index.. String { + data.map { String(format: "%02x", $0) }.joined() +} + +@Test func TestWalletImportP256HpkeMatchesRfc9180TestVector() throws { + typealias Vector = RFC9180P256Sha256Aes256GcmBase + let ephemeral = try P256.KeyAgreement.PrivateKey(rawRepresentation: hex(Vector.skEm)) + let recipient = try P256.KeyAgreement.PublicKey(x963Representation: hex(Vector.pkRm)) + #expect(hex(ephemeral.publicKey.x963Representation) == Vector.pkEm) + #expect(try P256.KeyAgreement.PrivateKey(rawRepresentation: hex(Vector.skRm)).publicKey.x963Representation == hex(Vector.pkRm)) + + let context = try P256HPKE.setupBaseSender( + recipientPublicKey: recipient.derRepresentation, + ephemeralPrivateKey: ephemeral, + info: hex(Vector.info) + ) + #expect(hex(context.encapsulatedKey) == Vector.enc) + #expect(hex(context.sharedSecret) == Vector.sharedSecret) + #expect(hex(context.keyScheduleContext) == Vector.keyScheduleContext) + #expect(hex(context.secret) == Vector.secret) + #expect(hex(context.key) == Vector.key) + #expect(hex(context.baseNonce) == Vector.baseNonce) + + let sealed = try P256HPKE.seal( + recipientPublicKey: recipient.derRepresentation, + plaintext: hex(Vector.plaintext), + ephemeralPrivateKey: ephemeral, + info: hex(Vector.info), + aad: hex(Vector.aad) + ) + #expect(hex(sealed.encapsulatedKey) == Vector.enc) + #expect(hex(sealed.ciphertext) == Vector.ciphertext) +} + +// CryptoKit's HPKE API requires macOS 14 or iOS 17; Swift Testing skips this test on older systems. +@available(macOS 14.0, iOS 17.0, *) +@Test func TestWalletImportP256HpkeOpensWithCryptoKitRecipient() throws { + let recipientKey = P256.KeyAgreement.PrivateKey() + let plaintext = Data("0x".utf8) + Data(repeating: 0x31, count: 64) + + let sealed = try P256HPKE.seal( + recipientPublicKey: recipientKey.publicKey.derRepresentation, + plaintext: plaintext + ) + var recipient = try HPKE.Recipient( + privateKey: recipientKey, + ciphersuite: .P256_SHA256_AES_GCM_256, + info: Data(), + encapsulatedKey: sealed.encapsulatedKey + ) + #expect(try recipient.open(sealed.ciphertext, authenticating: Data()) == plaintext) + + var tampered = sealed.ciphertext + tampered[tampered.startIndex] ^= 0x01 + var tamperedRecipient = try HPKE.Recipient( + privateKey: recipientKey, + ciphersuite: .P256_SHA256_AES_GCM_256, + info: Data(), + encapsulatedKey: sealed.encapsulatedKey + ) + #expect(throws: (any Error).self) { + try tamperedRecipient.open(tampered, authenticating: Data()) + } + + // CryptoKit also opens the RFC 9180 vector ciphertext, cross-checking the vector itself. + typealias Vector = RFC9180P256Sha256Aes256GcmBase + var vectorRecipient = try HPKE.Recipient( + privateKey: P256.KeyAgreement.PrivateKey(rawRepresentation: hex(Vector.skRm)), + ciphersuite: .P256_SHA256_AES_GCM_256, + info: hex(Vector.info), + encapsulatedKey: hex(Vector.enc) + ) + #expect(try vectorRecipient.open(hex(Vector.ciphertext), authenticating: hex(Vector.aad)) == hex(Vector.plaintext)) +} From 139f1bcd2084bb887da824abf2acb5b480799582 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 16:23:32 +0300 Subject: [PATCH 03/35] refactor!: align public API with the TypeScript and Kotlin SDKs --- API.md | 125 +++++++---- .../oms-wallet-demo/ContentView.swift | 14 +- .../trails-actions/TrailsDemoViewModel.swift | 10 +- MIGRATION.md | 171 ++++++++++++++- README.md | 75 ++++--- Sources/OMSWallet/Clients/WalletAuth.swift | 50 +++-- Sources/OMSWallet/Clients/WalletClient.swift | 8 +- Sources/OMSWallet/Clients/WalletImports.swift | 8 +- .../OMSWallet/Clients/WalletOperations.swift | 97 ++++----- .../OMSWallet/Clients/WalletSessions.swift | 25 +-- .../Models/Auth/PendingWalletSelection.swift | 12 +- ...ult.swift => WalletActivationResult.swift} | 2 +- .../Models/Indexer/SolanaIndexerModels.swift | 91 ++++++++ .../Models/Indexer/TronIndexerModels.swift | 91 ++++++++ Sources/OMSWallet/Models/OMSWalletError.swift | 21 +- .../Models/Sessions/OMSWalletSession.swift | 28 ++- Sources/OMSWallet/Network.swift | 15 +- Sources/OMSWallet/Utils/UnitHelpers.swift | 4 +- Sources/OMSWallet/WalletImport.swift | 2 +- .../ActiveWalletSessionTests.swift | 4 +- Tests/OMSWalletTests/IndexerTests.swift | 52 ++++- Tests/OMSWalletTests/MockWalletTests.swift | 109 ++++++---- .../PublicErrorContractsTests.swift | 55 +++++ Tests/OMSWalletTests/RequestsTests.swift | 6 +- .../SignatureVerificationTests.swift | 197 ++++++++++++++++++ Tests/OMSWalletTests/TronWalletTests.swift | 14 +- docs/error-contracts.md | 5 + scripts/api-presentation.json | 17 +- scripts/public-api-baseline.txt | 89 +++++--- 29 files changed, 1083 insertions(+), 314 deletions(-) rename Sources/OMSWallet/Models/Auth/{WalletSelectionResult.swift => WalletActivationResult.swift} (76%) create mode 100644 Tests/OMSWalletTests/SignatureVerificationTests.swift diff --git a/API.md b/API.md index d62ee8b..d64fbe2 100644 --- a/API.md +++ b/API.md @@ -31,12 +31,6 @@ public internal(set) var activeWallet: Wallet? { get set } The active wallet, or `nil` until auth completes or a session is restored, and after sign-out or session expiry. Same shape as `listWallets()` entries. -### `WalletClient.walletId` - -```swift -public internal(set) var walletId: String { get set } -``` - ### `WalletClient.session` ```swift @@ -46,6 +40,23 @@ public var session: OMSWalletSession? { get } Expiry and auth metadata for the active wallet session. Non-`nil` exactly when `activeWallet` is. +### `WalletClient.defaultSessionLifetimeSeconds` + +```swift +public static let defaultSessionLifetimeSeconds: UInt32 +``` + +The session lifetime used when a sign-in call does not pass `sessionLifetimeSeconds` +(7 days). + +### `WalletClient.maxSessionLifetimeSeconds` + +```swift +public static let maxSessionLifetimeSeconds: UInt32 +``` + +The longest session lifetime the wallet API accepts (30 days). + ### `WalletClient.addSessionExpiredObserver(_:)` ```swift @@ -55,7 +66,7 @@ Expiry and auth metadata for the active wallet session. Non-`nil` exactly when ### `WalletClient.startEmailAuth(email:sessionLifetimeSeconds:)` ```swift -public func startEmailAuth(email: String, sessionLifetimeSeconds: UInt32 = 604_800) async throws +public func startEmailAuth(email: String, sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds) async throws ``` Initiates email-based OTP authentication by sending a one-time code to the given address. @@ -71,23 +82,23 @@ Completes the email OTP authentication flow. ### `WalletClient.signInWithOidcIdToken(idToken:issuer:audience:walletType:walletSelection:sessionLifetimeSeconds:provider:providerLabel:)` ```swift -@discardableResult public func signInWithOidcIdToken(idToken: String, issuer: String, audience: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior = .automatic, sessionLifetimeSeconds: UInt32 = 604_800, provider: String? = nil, providerLabel: String? = nil) async throws -> CompleteAuthResult +@discardableResult public func signInWithOidcIdToken(idToken: String, issuer: String, audience: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior = .automatic, sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds, provider: String? = nil, providerLabel: String? = nil) async throws -> CompleteAuthResult ``` Signs in with an OIDC ID token. -### `WalletClient.startOIDCRedirectAuth(provider:walletType:loginHint:authorizeParams:walletSelection:sessionLifetimeSeconds:)` +### `WalletClient.startOIDCRedirectAuth(provider:walletType:walletSelection:sessionLifetimeSeconds:loginHint:authorizeParams:)` ```swift -public func startOIDCRedirectAuth(provider: CustomOIDCProviderConfiguration, walletType: WalletType = WalletType.ethereum, loginHint: String? = nil, authorizeParams: [String : String] = [:], walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil) async throws -> StartOIDCRedirectAuthResult +public func startOIDCRedirectAuth(provider: CustomOIDCProviderConfiguration, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil, loginHint: String? = nil, authorizeParams: [String : String] = [:]) async throws -> StartOIDCRedirectAuthResult ``` Starts OIDC authorization-code redirect authentication. -### `WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:loginHint:walletSelection:sessionLifetimeSeconds:)` +### `WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:walletSelection:sessionLifetimeSeconds:loginHint:)` ```swift -public func startOIDCRedirectAuth(provider: OMSRelayOIDCProvider, omsRelayReturnURI: String, walletType: WalletType = WalletType.ethereum, loginHint: String? = nil, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil) async throws -> StartOIDCRedirectAuthResult +public func startOIDCRedirectAuth(provider: OMSRelayOIDCProvider, omsRelayReturnURI: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil, loginHint: String? = nil) async throws -> StartOIDCRedirectAuthResult ``` Starts OIDC authorization-code redirect authentication through the built-in @@ -112,7 +123,7 @@ Lists all wallets available to the authenticated credential. ### `WalletClient.useWallet(walletId:)` ```swift -@discardableResult public func useWallet(walletId: String) async throws -> WalletSelectionResult +@discardableResult public func useWallet(walletId: String) async throws -> WalletActivationResult ``` Activates an existing wallet by its WaaS wallet ID and persists its address and @@ -121,7 +132,7 @@ signer metadata to the keychain. ### `WalletClient.createWallet(walletType:reference:)` ```swift -@discardableResult public func createWallet(walletType: WalletType = WalletType.ethereum, reference: String? = nil) async throws -> WalletSelectionResult +@discardableResult public func createWallet(walletType: WalletType = WalletType.ethereum, reference: String? = nil) async throws -> WalletActivationResult ``` Creates a new wallet of the specified type for the authenticated user and persists @@ -130,7 +141,7 @@ its address and signer metadata to the keychain. ### `WalletClient.importWallet(privateKey:reference:)` ```swift -@discardableResult public func importWallet(privateKey: WalletImportPrivateKey, reference: String? = nil) async throws -> WalletSelectionResult +@discardableResult public func importWallet(privateKey: WalletImportPrivateKey, reference: String? = nil) async throws -> WalletActivationResult ``` Imports and activates an Ethereum, Solana, or Tron private key. @@ -146,7 +157,7 @@ Fetches an attested recipient key for advanced wallet-import encryption flows. ### `WalletClient.importEncryptedWallet(walletType:keyMaterial:reference:)` ```swift -@discardableResult public func importEncryptedWallet(walletType: WalletType, keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil) async throws -> WalletSelectionResult +@discardableResult public func importEncryptedWallet(walletType: WalletType, keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil) async throws -> WalletActivationResult ``` Imports and activates key material encrypted by the caller for an attested WaaS recipient key. @@ -267,15 +278,15 @@ public final class PendingWalletSelection: @unchecked Sendable { public let walletType: WalletType public let wallets: [Wallet] public let credential: WalletCredential - @discardableResult public func selectWallet(walletId: String) async throws -> WalletSelectionResult - @discardableResult public func createAndSelectWallet(reference: String? = nil) async throws -> WalletSelectionResult + @discardableResult public func selectWallet(walletId: String) async throws -> WalletActivationResult + @discardableResult public func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult } ``` -### `WalletSelectionResult` +### `WalletActivationResult` ```swift -public struct WalletSelectionResult: Sendable { +public struct WalletActivationResult: Sendable { public let wallet: Wallet public init(wallet: Wallet) } @@ -349,6 +360,7 @@ public enum WalletImportPrivateKey: Sendable { case tron(String) /// A 32-byte secp256k1 private key. case tronBytes(Data) + public var walletType: WalletType { get } } ``` @@ -560,11 +572,12 @@ Result of handling an incoming OIDC authorization-code redirect callback. ```swift public struct OMSWalletSession: Equatable, Sendable { - /// Expiration time for the active wallet session. - public let expiresAt: Date + /// Expiration time for the active wallet session, as the ISO-8601 timestamp returned by the + /// wallet API (the same value as `WalletCredential.expiresAt`). + public let expiresAt: String /// Auth metadata for the active wallet session. public let auth: OMSWalletSessionAuth - public init(expiresAt: Date, auth: OMSWalletSessionAuth) + public init(expiresAt: String, auth: OMSWalletSessionAuth) } ``` @@ -627,8 +640,9 @@ public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { /// while a manual wallet selection was still pending. public let wallet: Wallet? public let session: OMSWalletSession - public let expiredAt: Date - public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: Date) + /// The ISO-8601 expiry timestamp of the session that expired. + public let expiredAt: String + public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: String) } ``` @@ -689,31 +703,31 @@ public func signTronTypedData(typedData: JSONValue) async throws -> String ### `WalletClient.isValidMessageSignature(network:walletAddress:message:signature:)` ```swift -public func isValidMessageSignature(network: Network, walletAddress: String, message: String, signature: String) async throws -> Bool +public func isValidMessageSignature(network: Network, walletAddress: String? = nil, message: String, signature: String) async throws -> Bool ``` ### `WalletClient.isValidSolanaMessageSignature(walletAddress:message:signature:)` ```swift -public func isValidSolanaMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool +public func isValidSolanaMessageSignature(walletAddress: String? = nil, message: String, signature: String) async throws -> Bool ``` ### `WalletClient.isValidTypedDataSignature(network:walletAddress:typedData:signature:)` ```swift -public func isValidTypedDataSignature(network: Network, walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool +public func isValidTypedDataSignature(network: Network, walletAddress: String? = nil, typedData: JSONValue, signature: String) async throws -> Bool ``` ### `WalletClient.isValidTronMessageSignature(walletAddress:message:signature:)` ```swift -public func isValidTronMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool +public func isValidTronMessageSignature(walletAddress: String? = nil, message: String, signature: String) async throws -> Bool ``` ### `WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:)` ```swift -public func isValidTronTypedDataSignature(walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool +public func isValidTronTypedDataSignature(walletAddress: String? = nil, typedData: JSONValue, signature: String) async throws -> Bool ``` ### `WalletClient.sendTransaction(network:to:value:selectFeeOption:mode:waitForStatus:statusPolling:)` @@ -740,16 +754,16 @@ public func sendSolanaTransfer(network: SolanaNetwork, asset: String, to: String public func sendTronTransaction(network: TronNetwork, to: String, value: String = "0", data: String? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse ``` -### `WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)` +### `WalletClient.callContract(network:contractAddress:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)` ```swift -public func callContract(network: Network, contract: String, method: String, args: [AbiArg]?, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +public func callContract(network: Network, contractAddress: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse ``` -### `WalletClient.callTronContract(network:contract:method:args:selectFeeOption:waitForStatus:statusPolling:)` +### `WalletClient.callTronContract(network:contractAddress:method:args:selectFeeOption:waitForStatus:statusPolling:)` ```swift -public func callTronContract(network: TronNetwork, contract: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +public func callTronContract(network: TronNetwork, contractAddress: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse ``` ### `WalletClient.getTransactionStatus(txnId:)` @@ -987,6 +1001,19 @@ public enum SolanaBalance: Decodable, Sendable { case native(SolanaNativeBalance) case fungibleToken(SolanaFungibleTokenBalance) public init(from decoder: Decoder) throws + public var network: SolanaNetwork { get } + public var accountAddress: String { get } + public var name: String { get } + public var symbol: String { get } + public var decimals: Int { get } + public var balance: String { get } + public var formattedBalance: String { get } + public var imageUrl: String? { get } + public var metadataUri: String? { get } + public var verificationStatus: SolanaVerificationStatus { get } + public var verificationSource: SolanaVerificationSource { get } + public var priceUSD: String? { get } + public var balanceUSD: String? { get } } ``` @@ -1116,6 +1143,19 @@ public enum TronBalance: Decodable, Sendable { case native(TronNativeBalance) case fungibleToken(TronFungibleTokenBalance) public init(from decoder: Decoder) throws + public var network: TronNetwork { get } + public var accountAddress: String { get } + public var name: String { get } + public var symbol: String { get } + public var decimals: Int { get } + public var balance: String { get } + public var formattedBalance: String { get } + public var imageUrl: String? { get } + public var metadataUri: String? { get } + public var verificationStatus: TronVerificationStatus { get } + public var verificationSource: String { get } + public var priceUSD: String? { get } + public var balanceUSD: String? { get } } ``` @@ -1539,7 +1579,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { case mainnet case sepolia case polygon - case polygonAmoy = "amoy" + case amoy case arbitrum case arbitrumSepolia = "arbitrum-sepolia" case optimism @@ -1552,9 +1592,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { case avalanche case avalancheTestnet = "avalanche-testnet" case katana - public static let amoy: Network public var id: Int { get } - public var chainId: String { get } public var name: String { get } public var nativeTokenSymbol: String { get } public var explorerUrl: String { get } @@ -1660,6 +1698,7 @@ public enum OMSWalletErrorCode: String, Sendable { case validationError = "OMS_VALIDATION_ERROR" case storageError = "OMS_STORAGE_ERROR" case attestationVerificationFailed = "OMS_ATTESTATION_VERIFICATION_FAILED" + case walletAddressAlreadyImported = "OMS_WALLET_ADDRESS_ALREADY_IMPORTED" public init?(rawValue: String) } ``` @@ -1674,8 +1713,8 @@ public enum OMSWalletOperation: String, Sendable { case walletStartEmailAuth = "wallet.startEmailAuth" case walletCompleteEmailAuth = "wallet.completeEmailAuth" case walletSignInWithOidcIdToken = "wallet.signInWithOidcIdToken" - case walletStartOIDCRedirectAuth = "wallet.startOIDCRedirectAuth" - case walletHandleOIDCRedirectCallback = "wallet.handleOIDCRedirectCallback" + case walletStartOIDCRedirectAuth = "wallet.startOidcRedirectAuth" + case walletHandleOIDCRedirectCallback = "wallet.handleOidcRedirectCallback" case walletUseWallet = "wallet.useWallet" case walletCreateWallet = "wallet.createWallet" case walletImportWallet = "wallet.importWallet" @@ -1734,8 +1773,8 @@ public struct OMSWalletUpstreamError: Equatable, Sendable { ```swift public enum OMSWalletUpstreamService: String, Sendable { - case waas = "Waas" - case indexer = "Indexer" + case waas = "waas" + case indexer = "indexer" public init?(rawValue: String) } ``` @@ -1753,11 +1792,11 @@ public enum UnitConversionError: Error, Equatable { ### `parseUnits(value:decimals:)` ```swift -public func parseUnits(value: String, decimals: Int = 18) throws -> String +public func parseUnits(value: String, decimals: Int) throws -> String ``` ### `formatUnits(value:decimals:)` ```swift -public func formatUnits(value: String, decimals: Int = 18) throws -> String +public func formatUnits(value: String, decimals: Int) throws -> String ``` diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index 8257490..7d1f8d3 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -525,7 +525,7 @@ final class AppViewModel: ObservableObject { } private func completeWalletSelection( - _ operation: () async throws -> WalletSelectionResult + _ operation: () async throws -> WalletActivationResult ) async { isLoading = true defer { isLoading = false } @@ -1101,7 +1101,7 @@ struct WalletWindow: View { @State private var usdcBalance: String = "—" @State private var usdcBalanceRaw: String = "" @State private var isFetchingBalance: Bool = false - @State private var selectedNetwork: Network = Network.polygonAmoy + @State private var selectedNetwork: Network = Network.amoy private func clearBalance() { nativeBalance = "—" @@ -1759,7 +1759,7 @@ private struct TronSendWindow: View { // TRC-20 transfers are contract calls; the wallet service ABI-encodes the arguments. transaction = try await vm.omsWallet.wallet.callTronContract( network: .nile, - contract: contract, + contractAddress: contract, method: "transfer", args: [ AbiArg(type: "address", value: .string(recipient)), @@ -1853,7 +1853,7 @@ struct SignMessageWindow: View { var showsCloseButton: Bool = true @State private var messageText: String = "" - @State private var network: Network = Network.polygonAmoy + @State private var network: Network = Network.amoy @State private var signature: String = "" @State private var isSigning: Bool = false @State private var error: GenericAppError? @@ -1918,7 +1918,7 @@ struct SendTransactionWindow: View { @State private var toText: String = "" @State private var amountText: String = "1000" - @State private var network: Network = Network.polygonAmoy + @State private var network: Network = Network.amoy @State private var result: SendTransactionResponse? @State private var isSending: Bool = false @State private var error: GenericAppError? @@ -2047,7 +2047,7 @@ struct CallContractWindow: View { @State private var contractText: String = "0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582" @State private var methodText: String = "transfer" - @State private var network: Network = Network.polygonAmoy + @State private var network: Network = Network.amoy @State private var args: [AbiArgInput] = [ AbiArgInput(type: "address", value: "0xE5E8B483FfC05967FcFed58cc98D053265af6D99"), AbiArgInput(type: "uint256", value: "1000000"), @@ -2127,7 +2127,7 @@ struct CallContractWindow: View { .map { AbiArg(type: $0.type, value: parseAbiValue($0.value)) } let txResult = try await vm.omsWallet.wallet.callContract( network: network, - contract: contractText, + contractAddress: contractText, method: methodText, args: abiArgs.isEmpty ? nil : abiArgs, selectFeeOption: .custom { options in diff --git a/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift b/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift index 769548d..f4aa6cc 100644 --- a/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift +++ b/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift @@ -136,7 +136,7 @@ actor TrailsOMSWallet { fileprivate func selectWallet( _ pendingSelection: PendingWalletSelection, walletId: String - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { try await performThrowing { _ in try await pendingSelection.selectWallet(walletId: walletId) } @@ -145,7 +145,7 @@ actor TrailsOMSWallet { fileprivate func createAndSelectWallet( _ pendingSelection: PendingWalletSelection, reference: String? - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { try await performThrowing { _ in try await pendingSelection.createAndSelectWallet(reference: reference) } @@ -239,11 +239,11 @@ final class TrailsPendingWalletSelection: @unchecked Sendable { self.client = client } - func selectWallet(walletId: String) async throws -> WalletSelectionResult { + func selectWallet(walletId: String) async throws -> WalletActivationResult { try await client.selectWallet(pendingSelection, walletId: walletId) } - func createAndSelectWallet(reference: String? = nil) async throws -> WalletSelectionResult { + func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult { try await client.createAndSelectWallet(pendingSelection, reference: reference) } } @@ -885,7 +885,7 @@ final class TrailsDemoViewModel: ObservableObject { } } - private func handleWalletActivation(_ result: WalletSelectionResult, status: String) async { + private func handleWalletActivation(_ result: WalletActivationResult, status: String) async { pendingWalletSelection = nil authStatus = status redirectStatus = "" diff --git a/MIGRATION.md b/MIGRATION.md index c96322e..014f9b3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -21,7 +21,8 @@ if let activeWallet = omsWallet.wallet.activeWallet, activeWallet.type == .ether } ``` -`walletAddress` was also removed from `WalletSelectionResult` and from +`walletAddress` was also removed from `WalletSelectionResult` (now `WalletActivationResult`, see +below) and from `CompleteAuthResult.walletSelected`, which is now `.walletSelected(wallet:wallets:credential:)`. Use `result.wallet.address`: @@ -38,7 +39,8 @@ case .walletSelected(let wallet, let wallets, let credential): `OMSWalletSessionState` was renamed to `OMSWalletSession`, and `omsWallet.wallet.session` is now `OMSWalletSession?`. Previously it returned a value whose fields were all `nil`. The -`walletAddress` field was removed, and `expiresAt` and `auth` are no longer optional. `session` is +`walletAddress` field was removed, and `expiresAt` and `auth` are no longer optional (`expiresAt` is +now an ISO-8601 `String`; see [Session expiry timestamps](#session-expiry-timestamps)). `session` is non-`nil` exactly when `activeWallet` is. ```swift @@ -93,6 +95,171 @@ Ethereum wallets whose address is not `0x` followed by 40 hexadecimal digits are `.walletSendTronTransaction`, `.walletCallTronContract`, and `.indexerGetTronBalances`. Update exhaustive switches over these enums. +### Signature verification + +Every `isValid…Signature` method (`isValidMessageSignature`, `isValidTypedDataSignature`, +`isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature`) +now takes an optional `walletAddress`, and verification requests never send a wallet ID: + +- With `walletAddress`, no session is required. Previously the EVM methods threw `.sessionMissing` + when signed out even though an address was passed. +- Without `walletAddress`, the active wallet's address is used. Signed out, the call throws + `.sessionMissing`; if the active wallet belongs to another family (for example an Ethereum wallet + and `isValidSolanaMessageSignature`), it throws `.validationError` before any request. + +```swift +// 0.3.x +let isValid = try await omsWallet.wallet.isValidMessageSignature( + network: .amoy, + walletAddress: walletAddress, + message: message, + signature: signature +) + +// 0.4.0: verify against the active wallet +let isValid = try await omsWallet.wallet.isValidMessageSignature( + network: .amoy, + message: message, + signature: signature +) +``` + +### Contract call parameters + +`callContract` and `callTronContract` label the contract `contractAddress:` instead of +`contract:`. `callContract`'s `args` now defaults to `nil`, so a call without arguments can omit it. + +```swift +// 0.3.x +try await omsWallet.wallet.callContract(network: .amoy, contract: token, method: "mint", args: nil) + +// 0.4.0 +try await omsWallet.wallet.callContract(network: .amoy, contractAddress: token, method: "mint") +``` + +### `WalletActivationResult` + +`WalletSelectionResult` was renamed to `WalletActivationResult`. It is returned by `useWallet`, +`createWallet`, `importWallet`, `importEncryptedWallet`, and `PendingWalletSelection`'s +`selectWallet` and `createAndSelectWallet`. Its `wallet` property is unchanged. + +```swift +// 0.3.x +let result: WalletSelectionResult = try await omsWallet.wallet.createWallet() + +// 0.4.0 +let result: WalletActivationResult = try await omsWallet.wallet.createWallet() +``` + +### `walletId` removed from `WalletClient` + +`omsWallet.wallet.walletId` is no longer public. Read the ID from the active wallet: + +```swift +// 0.3.x +let walletId = omsWallet.wallet.walletId + +// 0.4.0 +let walletId = omsWallet.wallet.activeWallet?.id +``` + +### Session expiry timestamps + +`OMSWalletSession.expiresAt` and `OMSWalletSessionExpiredEvent.expiredAt` are now the ISO-8601 +`String` returned by the wallet API (the same value as `WalletCredential.expiresAt`) instead of a +`Date`. `OMSWalletSession.init(expiresAt:auth:)` and `OMSWalletSessionExpiredEvent.init` take a +`String`. Parse the value when you need a `Date`; the wallet API may include fractional seconds: + +```swift +// 0.3.x +let expiry: Date? = omsWallet.wallet.session.expiresAt + +// 0.4.0 +guard let session = omsWallet.wallet.session else { return } +let formatter = ISO8601DateFormatter() +formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] +let expiry = formatter.date(from: session.expiresAt) + ?? ISO8601DateFormatter().date(from: session.expiresAt) +``` + +### Networks + +`Network.polygonAmoy` was renamed to `Network.amoy` (raw value `"amoy"`, chain ID `80002`), and the +duplicate `Network.amoy` static alias was removed. `Network.findByName(_:)` no longer accepts the +`"polygonamoy"` alias; pass `"amoy"`. `Network.chainId` (the chain ID as a `String`) was removed; +use `id`. + +```swift +// 0.3.x +let network = Network.polygonAmoy +let chainId = network.chainId + +// 0.4.0 +let network = Network.amoy +let chainId = String(network.id) +``` + +### Unit helpers require `decimals` + +`parseUnits(value:decimals:)` and `formatUnits(value:decimals:)` no longer default `decimals` to +`18`. Pass the token's decimals explicitly: + +```swift +// 0.3.x +let wei = try parseUnits(value: "0.001") + +// 0.4.0 +let wei = try parseUnits(value: "0.001", decimals: 18) +``` + +### OIDC redirect parameter order + +`startOIDCRedirectAuth` parameters now follow the order used by the other OMS Wallet SDKs. Defaults +are unchanged. Calls that pass `loginHint` or `authorizeParams` together with `walletSelection` or +`sessionLifetimeSeconds` must reorder those arguments: + +```swift +// 0.3.x +startOIDCRedirectAuth(provider:walletType:loginHint:authorizeParams:walletSelection:sessionLifetimeSeconds:) +startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:loginHint:walletSelection:sessionLifetimeSeconds:) + +// 0.4.0 +startOIDCRedirectAuth(provider:walletType:walletSelection:sessionLifetimeSeconds:loginHint:authorizeParams:) +startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:walletSelection:sessionLifetimeSeconds:loginHint:) +``` + +### Error and operation strings + +These changes compile without edits, so check code that stores, logs, or compares raw values: + +- `OMSWalletOperation.walletStartOIDCRedirectAuth.rawValue` is now `"wallet.startOidcRedirectAuth"` + (was `"wallet.startOIDCRedirectAuth"`), and `.walletHandleOIDCRedirectCallback.rawValue` is now + `"wallet.handleOidcRedirectCallback"` (was `"wallet.handleOIDCRedirectCallback"`). Case names are + unchanged. +- `OMSWalletUpstreamService` raw values are now `"waas"` and `"indexer"` (were `"Waas"` and + `"Indexer"`). +- `OMSWalletErrorCode` gained `.walletAddressAlreadyImported` (`OMS_WALLET_ADDRESS_ALREADY_IMPORTED`). + Importing a key whose address is already managed now throws it with `status` `409` and + `retryable == false`; it previously surfaced as `.requestFailed`. Update exhaustive switches over + `OMSWalletErrorCode`. + +```swift +// 0.3.x +if error.code == .requestFailed, error.upstreamError?.code == "7313" { /* already imported */ } + +// 0.4.0 +if error.code == .walletAddressAlreadyImported { /* already imported */ } +``` + +### New members that may shadow app extensions + +`TronBalance` and `SolanaBalance` now expose `network`, `accountAddress`, `name`, `symbol`, +`decimals`, `balance`, `formattedBalance`, `imageUrl`, `metadataUri`, `verificationStatus`, +`verificationSource`, `priceUSD`, and `balanceUSD` directly. `WalletImportPrivateKey.walletType`, +`WalletClient.defaultSessionLifetimeSeconds` (604,800), and +`WalletClient.maxSessionLifetimeSeconds` (2,592,000) are now public. Remove app extensions that +declare members with these names on those types. + ## 0.3.0 ### Wallet types and key origin diff --git a/README.md b/README.md index 2094089..cbc73ea 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ if let wallet = auth.wallet { print("Wallet address:", wallet.address) let signature = try await omsWallet.wallet.signMessage( - network: .polygonAmoy, + network: .amoy, message: "hello from OMS Wallet" ) print("Signature:", signature) @@ -97,7 +97,7 @@ Pass your OMS publishable key when creating the client. The SDK derives the wall ## Security Model -Wallet API requests are signed with a non-extractable Keychain P-256 credential using the `webcrypto-secp256r1` key type. The credential remains Keychain-managed and is not serialized into SDK session storage. +Wallet API requests are signed with a non-extractable Keychain P-256 credential using the `ecdsa-p256-sha256` signing algorithm. The credential remains Keychain-managed and is not serialized into SDK session storage. Only completed wallet session metadata is restored automatically, including the active wallet (ID, type, address, reference, and key origin), expiry, and auth metadata such as email or OIDC issuer/provider details when available. The SDK checks the cached session expiry before restoring a session. Expired sessions are not activated, and invalid session metadata is cleared; expired session metadata remains in storage as a reauth hint until `signOut()` or a new auth flow clears or replaces it. @@ -128,11 +128,14 @@ if let activeWallet = omsWallet.wallet.activeWallet, ``` `activeWallet` is a `Wallet`, the same type `listWallets()` returns, or `nil` when signed out. -`session` holds the expiry and auth metadata and is non-`nil` exactly when `activeWallet` is. +`session` holds the expiry and auth metadata and is non-`nil` exactly when `activeWallet` is. Its +`expiresAt` is the ISO-8601 timestamp returned by the wallet API, the same value as +`WalletCredential.expiresAt`. Pass `sessionLifetimeSeconds` to `startEmailAuth` when you need a shorter or -longer email session. The default is one week, and custom values must be from 1 -through 2,592,000 seconds (30 days). The SDK validates the value before sending +longer email session. The default is `WalletClient.defaultSessionLifetimeSeconds` (one week), and +custom values must be from 1 through `WalletClient.maxSessionLifetimeSeconds` (2,592,000 seconds, +30 days). The SDK validates the value before sending the one-time code. Use `addSessionExpiredObserver` when your app needs to react to session expiry: @@ -317,30 +320,30 @@ verified. ```swift let signature = try await omsWallet.wallet.signMessage( - network: .polygonAmoy, + network: .amoy, message: "hello from OMS Wallet" ) -guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } - +// Omit `walletAddress` to verify against the active wallet, or pass any address to verify +// without a session. let isValid = try await omsWallet.wallet.isValidMessageSignature( - network: .polygonAmoy, - walletAddress: walletAddress, + network: .amoy, message: "hello from OMS Wallet", signature: signature ) ``` +Every `isValid…Signature` method takes an optional `walletAddress`. With an address it needs no +session and ignores the active wallet. Without one it uses the active wallet's address: it throws +`.sessionMissing` when signed out and `.validationError` when the active wallet belongs to another +chain family (for example an Ethereum wallet passed to `isValidSolanaMessageSignature`). + For a selected Solana wallet, use the Solana-specific message methods; off-chain messages do not require a cluster: ```swift -guard let activeWallet = omsWallet.wallet.activeWallet, activeWallet.type == .solana else { return } -let solanaWalletAddress = activeWallet.address - let signature = try await omsWallet.wallet.signSolanaMessage(message: "hello from Solana") let valid = try await omsWallet.wallet.isValidSolanaMessageSignature( - walletAddress: solanaWalletAddress, message: "hello from Solana", signature: signature ) @@ -370,15 +373,12 @@ let typedData: JSONValue = .object([ ]) let signature = try await omsWallet.wallet.signTypedData( - network: .polygonAmoy, + network: .amoy, typedData: typedData ) -guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } - let typedDataValid = try await omsWallet.wallet.isValidTypedDataSignature( - network: .polygonAmoy, - walletAddress: walletAddress, + network: .amoy, typedData: typedData, signature: signature ) @@ -439,7 +439,7 @@ guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } let history = try await omsWallet.indexer.getTransactionHistory( GetTransactionHistoryParams( walletAddress: walletAddress, - networks: [.polygonAmoy], + networks: [.amoy], includeMetadata: true ) ) @@ -471,7 +471,7 @@ transaction is sponsored. Transaction mode defaults to `.relayer`; pass ```swift let value = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0x1111111111111111111111111111111111111111", value: value ) @@ -490,7 +490,7 @@ transaction hash, `.timedOut` when the polling deadline expires first, and ```swift let value = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest( to: "0x1111111111111111111111111111111111111111", value: value, @@ -505,8 +505,8 @@ let txResult = try await omsWallet.wallet.sendTransaction( ```swift let amount = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.callContract( - network: .polygonAmoy, - contract: "0x3333333333333333333333333333333333333333", + network: .amoy, + contractAddress: "0x3333333333333333333333333333333333333333", method: "transfer", args: [ AbiArg(type: "address", value: .string("0x1111111111111111111111111111111111111111")), @@ -526,7 +526,7 @@ returned `txnId`. The immediate response has `statusResolution == .notRequested` ```swift let value = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0x1111111111111111111111111111111111111111", value: value, waitForStatus: false @@ -552,7 +552,7 @@ To tune polling, pass `statusPolling`: ```swift let value = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0x1111111111111111111111111111111111111111", value: value, statusPolling: TransactionStatusPollingOptions( @@ -568,7 +568,7 @@ from the returned fee options: ```swift let value = try parseUnits(value: "0.001", decimals: 18) let txResult = try await omsWallet.wallet.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0x1111111111111111111111111111111111111111", value: value, selectFeeOption: .custom { options in @@ -620,7 +620,7 @@ let trxTransfer = try await omsWallet.wallet.sendTronTransaction( // TRC-20 transfer. The wallet service ABI-encodes the call; address arguments accept `T…`. let trc20Transfer = try await omsWallet.wallet.callTronContract( network: .nile, - contract: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + contractAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", method: "transfer", args: [ AbiArg(type: "address", value: .string("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")), @@ -644,9 +644,12 @@ WaaS quotes the TRX to burn as a single native fee option, which a fee selector other fee option. Use `getTronBalances` for TRX and TRC-20 balances. Omit `networks` to query both Tron Mainnet and -Nile, or pass either network explicitly. Results have the same shape as `getSolanaBalances`: +Nile, or pass either network explicitly. Results have the same structure as `getSolanaBalances`: precision-safe raw and formatted balance strings, with TRC-20 entries identified by -`contractAddress`. Pass `contractAddresses` or `excludedContractAddresses` to filter tokens. +`tokenStandard` and `contractAddress` where Solana token entries use `tokenProgram` and +`mintAddress`. `TronBalance` and `SolanaBalance` expose the fields shared by both cases (`network`, +`accountAddress`, `symbol`, `decimals`, `balance`, `formattedBalance`, and so on) directly, so you +only need to switch for the token-specific fields. Pass `contractAddresses` or `excludedContractAddresses` to filter tokens. Individual network failures are reported in `errors` without discarding balances returned by the other requested network. @@ -683,7 +686,8 @@ the target environment rather than passing custom endpoint defaults in app code. Use `Network.supportedNetworks`, `Network.findById(_:)`, and `Network.findByName(_:)` to bind numeric chain IDs and network names to SDK -networks. `polygonamoy` is also accepted as a lookup alias for `.polygonAmoy`. +networks. `findByName(_:)` matches the indexer value in the table below, ignoring case and +surrounding whitespace. ```swift let networks = Network.supportedNetworks @@ -698,7 +702,7 @@ let katana = Network.findByName("katana") | `1` | Ethereum | `.mainnet` | `mainnet` | `ETH` | | `11155111` | Sepolia | `.sepolia` | `sepolia` | `ETH` | | `137` | Polygon | `.polygon` | `polygon` | `POL` | -| `80002` | Polygon Amoy | `.polygonAmoy` | `amoy` | `POL` | +| `80002` | Polygon Amoy | `.amoy` | `amoy` | `POL` | | `42161` | Arbitrum | `.arbitrum` | `arbitrum` | `ETH` | | `421614` | Arbitrum Sepolia | `.arbitrumSepolia` | `arbitrum-sepolia` | `ETH` | | `10` | Optimism | `.optimism` | `optimism` | `ETH` | @@ -762,11 +766,16 @@ polling failed, so retry status lookup with the returned `txnId`. is still running; wait for it to finish before starting another. `retryable` describes the failed SDK operation, not the whole user intent. +`upstreamError.service` is `.waas` or `.indexer` (raw values `"waas"` and `"indexer"`), and +`upstreamError.code` is a string; numeric WebRPC codes are stringified (for example `"7313"`). +Wallet import reports an address that is already managed as `.walletAddressAlreadyImported` +(status `409`, `retryable == false`); select the existing wallet or use a different key. + ```swift let value = try parseUnits(value: "0.001", decimals: 18) do { let txResult = try await omsWallet.wallet.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0x1111111111111111111111111111111111111111", value: value ) diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index c2f8795..641d41e 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -14,7 +14,7 @@ extension WalletClient { /// 2,592,000 seconds (30 days). public func startEmailAuth( email: String, - sessionLifetimeSeconds: UInt32 = 604_800 + sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds ) async throws { try await runOMSWalletOperation(.walletStartEmailAuth) { let validatedSessionLifetimeSeconds = try requireWaasSessionLifetimeSeconds(sessionLifetimeSeconds) @@ -85,7 +85,7 @@ extension WalletClient { audience: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior = .automatic, - sessionLifetimeSeconds: UInt32 = 604_800, + sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds, provider: String? = nil, providerLabel: String? = nil ) async throws -> CompleteAuthResult { @@ -145,10 +145,10 @@ extension WalletClient { public func startOIDCRedirectAuth( provider: CustomOIDCProviderConfiguration, walletType: WalletType = WalletType.ethereum, - loginHint: String? = nil, - authorizeParams: [String: String] = [:], walletSelection: WalletSelectionBehavior? = nil, - sessionLifetimeSeconds: UInt32? = nil + sessionLifetimeSeconds: UInt32? = nil, + loginHint: String? = nil, + authorizeParams: [String: String] = [:] ) async throws -> StartOIDCRedirectAuthResult { try await runOMSWalletOperation(.walletStartOIDCRedirectAuth) { guard !provider.providerRedirectURI.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -174,9 +174,9 @@ extension WalletClient { provider: OMSRelayOIDCProvider, omsRelayReturnURI: String, walletType: WalletType = WalletType.ethereum, - loginHint: String? = nil, walletSelection: WalletSelectionBehavior? = nil, - sessionLifetimeSeconds: UInt32? = nil + sessionLifetimeSeconds: UInt32? = nil, + loginHint: String? = nil ) async throws -> StartOIDCRedirectAuthResult { try await runOMSWalletOperation(.walletStartOIDCRedirectAuth) { guard !omsRelayReturnURI.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -376,7 +376,7 @@ extension WalletClient { ) let resolvedWalletSelection = walletSelection ?? pending.walletSelection ?? .automatic let resolvedSessionLifetimeSeconds = try requireWaasSessionLifetimeSeconds( - sessionLifetimeSeconds ?? pending.sessionLifetimeSeconds ?? defaultWaasSessionLifetimeSeconds + sessionLifetimeSeconds ?? pending.sessionLifetimeSeconds ?? WalletClient.defaultSessionLifetimeSeconds ) let response = try await signedClient.completeAuth( CompleteAuthRequest( @@ -610,7 +610,7 @@ extension WalletClient { ) } - let activated: WalletSelectionResult + let activated: WalletActivationResult if let selectedWallet = candidateWallets.first { if oidcRedirectAuthOwnership == nil { activated = try await signOutOnFailure { @@ -721,12 +721,11 @@ extension WalletClient { guard activePendingWalletSelection?.id == selectionSession.id else { throw OMSWalletError.walletSelectionStale() } - if let expiresAt = OMSWalletSession.parseDate(selectionSession.metadata.expiresAt) { - let selectionSessionState = WalletSessionSnapshot( - wallet: nil, - expiresAt: expiresAt, - auth: selectionSession.metadata.auth - ) + if let selectionSessionState = WalletSessionSnapshot( + wallet: nil, + expiresAt: selectionSession.metadata.expiresAt, + auth: selectionSession.metadata.auth + ) { guard !isSessionExpired(selectionSessionState) else { expireSession(selectionSessionState) throw OMSWalletError.sessionExpired() @@ -754,7 +753,7 @@ extension WalletClient { /// Activates an existing wallet by its WaaS wallet ID and persists its address and /// signer metadata to the keychain. @discardableResult - public func useWallet(walletId: String) async throws -> WalletSelectionResult { + public func useWallet(walletId: String) async throws -> WalletActivationResult { try await runOMSWalletOperation(.walletUseWallet) { try requireWalletSelectionOrActiveSession() try requireActiveCredential() @@ -778,7 +777,7 @@ extension WalletClient { public func createWallet( walletType: WalletType = WalletType.ethereum, reference: String? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { try await runOMSWalletOperation(.walletCreateWallet) { try requireWalletSelectionOrActiveSession() try requireActiveCredential() @@ -807,7 +806,7 @@ extension WalletClient { sessionMetadata: SessionMetadata, requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { let params = CreateWalletRequest( networkFamily: walletType.waasNetworkFamily, reference: reference @@ -822,7 +821,7 @@ extension WalletClient { oidcRedirectAuthOwnership: oidcRedirectAuthOwnership ) - return WalletSelectionResult(wallet: wallet) + return WalletActivationResult(wallet: wallet) } /// Loads an existing wallet by ID for the authenticated user and persists @@ -837,7 +836,7 @@ extension WalletClient { sessionMetadata: SessionMetadata, requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { let params = UseWalletRequest( walletId: walletId ) @@ -851,7 +850,7 @@ extension WalletClient { oidcRedirectAuthOwnership: oidcRedirectAuthOwnership ) - return WalletSelectionResult(wallet: wallet) + return WalletActivationResult(wallet: wallet) } private func walletsFromAuthResponse(_ response: CompleteAuthResponse) async throws -> [Wallet] { @@ -888,14 +887,13 @@ extension WalletClient { } -private let defaultWaasSessionLifetimeSeconds: UInt32 = 604_800 -private let maxWaasSessionLifetimeSeconds: UInt32 = 2_592_000 - +@available(macOS 12.0, iOS 15.0, *) private func requireWaasSessionLifetimeSeconds(_ sessionLifetimeSeconds: UInt32) throws -> UInt32 { - guard sessionLifetimeSeconds >= 1 && sessionLifetimeSeconds <= maxWaasSessionLifetimeSeconds else { + let maxSessionLifetimeSeconds = WalletClient.maxSessionLifetimeSeconds + guard sessionLifetimeSeconds >= 1 && sessionLifetimeSeconds <= maxSessionLifetimeSeconds else { throw OMSWalletError( code: .validationError, - message: "sessionLifetimeSeconds must be an integer between 1 and \(maxWaasSessionLifetimeSeconds)" + message: "sessionLifetimeSeconds must be an integer between 1 and \(maxSessionLifetimeSeconds)" ) } return sessionLifetimeSeconds diff --git a/Sources/OMSWallet/Clients/WalletClient.swift b/Sources/OMSWallet/Clients/WalletClient.swift index 0115f16..42df6eb 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -17,6 +17,12 @@ public final class WalletClient: @unchecked Sendable { event: OMSWalletSessionExpiredEvent ) + /// The session lifetime used when a sign-in call does not pass `sessionLifetimeSeconds` + /// (7 days). + public static let defaultSessionLifetimeSeconds: UInt32 = 604_800 + /// The longest session lifetime the wallet API accepts (30 days). + public static let maxSessionLifetimeSeconds: UInt32 = 2_592_000 + static let defaultTransactionStatusPollTimeoutMs: UInt64 = 60_000 static let defaultFastTransactionStatusPollIntervalMs: UInt64 = 400 static let defaultFastTransactionStatusPollCount = 5 @@ -107,7 +113,7 @@ public final class WalletClient: @unchecked Sendable { withSessionLock { _activeWallet = newValue } } } - public internal(set) var walletId: String { + var walletId: String { get { withSessionLock { _walletId } } diff --git a/Sources/OMSWallet/Clients/WalletImports.swift b/Sources/OMSWallet/Clients/WalletImports.swift index 9f892e6..ef69a9d 100644 --- a/Sources/OMSWallet/Clients/WalletImports.swift +++ b/Sources/OMSWallet/Clients/WalletImports.swift @@ -31,7 +31,7 @@ extension WalletClient { public func importWallet( privateKey: WalletImportPrivateKey, reference: String? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { try await runOMSWalletOperation(.walletImportWallet) { let context = try walletImportActivationContext(for: privateKey.walletType) try WalletImportValidation.validateReference(reference) @@ -82,7 +82,7 @@ extension WalletClient { walletType: WalletType, keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { try await runOMSWalletOperation(.walletImportEncryptedWallet) { let context = try walletImportActivationContext(for: walletType) try WalletImportValidation.validateReference(reference) @@ -191,7 +191,7 @@ extension WalletClient { private func activateImportedWallet( _ wallet: Wallet, context: WalletImportActivationContext - ) throws -> WalletSelectionResult { + ) throws -> WalletActivationResult { try createSequenceWallet( wallet: wallet, sessionMetadata: context.metadata, @@ -200,7 +200,7 @@ extension WalletClient { if case .pending = context { activePendingWalletSelection = nil } - return WalletSelectionResult(wallet: wallet) + return WalletActivationResult(wallet: wallet) } private func requireWalletImportClient() throws -> WaasClient { diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 23e8748..2767faa 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -14,7 +14,7 @@ extension WalletClient { let walletId = try requireActiveWalletId() try requireActiveCredential() let params = SignMessageRequest( - network: network.chainId, + network: network.waasChainId, walletId: walletId, message: message ) @@ -41,7 +41,7 @@ extension WalletClient { let walletId = try requireActiveWalletId() try requireActiveCredential() let params = SignTypedDataRequest( - network: network.chainId, + network: network.waasChainId, walletId: walletId, typedData: typedData.waasValue ) @@ -53,18 +53,17 @@ extension WalletClient { public func isValidMessageSignature( network: Network, - walletAddress: String, + walletAddress: String? = nil, message: String, signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidMessageSignature) { - let walletId = try requireActiveWalletId() + let walletAddress = try verificationWalletAddress(walletAddress, walletType: .ethereum) let response = try await publicClient.isValidMessageSignature( IsValidMessageSignatureRequest( - network: network.chainId, + network: network.waasChainId, networkFamily: .evm, walletAddress: walletAddress, - walletId: walletId, message: message, signature: signature ) @@ -75,11 +74,12 @@ extension WalletClient { } public func isValidSolanaMessageSignature( - walletAddress: String, + walletAddress: String? = nil, message: String, signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidSolanaMessageSignature) { + let walletAddress = try verificationWalletAddress(walletAddress, walletType: .solana) let response = try await publicClient.isValidMessageSignature( IsValidMessageSignatureRequest( networkFamily: .solana, @@ -94,17 +94,17 @@ extension WalletClient { public func isValidTypedDataSignature( network: Network, - walletAddress: String, + walletAddress: String? = nil, typedData: JSONValue, signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidTypedDataSignature) { - let walletId = try requireActiveWalletId() + let walletAddress = try verificationWalletAddress(walletAddress, walletType: .ethereum) let response = try await publicClient.isValidTypedDataSignature( IsValidTypedDataSignatureRequest( - network: network.chainId, + network: network.waasChainId, + networkFamily: .evm, walletAddress: walletAddress, - walletId: walletId, typedData: typedData.waasValue, signature: signature ) @@ -137,12 +137,13 @@ extension WalletClient { } public func isValidTronMessageSignature( - walletAddress: String, + walletAddress: String? = nil, message: String, signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidTronMessageSignature) { - try await publicClient.isValidMessageSignature( + let walletAddress = try verificationWalletAddress(walletAddress, walletType: .tron) + return try await publicClient.isValidMessageSignature( IsValidMessageSignatureRequest( networkFamily: .tron, walletAddress: walletAddress, @@ -154,12 +155,13 @@ extension WalletClient { } public func isValidTronTypedDataSignature( - walletAddress: String, + walletAddress: String? = nil, typedData: JSONValue, signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidTronTypedDataSignature) { - try await publicClient.isValidTypedDataSignature( + let walletAddress = try verificationWalletAddress(walletAddress, walletType: .tron) + return try await publicClient.isValidTypedDataSignature( IsValidTypedDataSignatureRequest( networkFamily: .tron, walletAddress: walletAddress, @@ -300,7 +302,7 @@ extension WalletClient { public func callTronContract( network: TronNetwork, - contract: String, + contractAddress: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, @@ -317,7 +319,7 @@ extension WalletClient { PrepareTronContractCallRequest( network: network.rawValue, walletId: walletId, - contract: contract, + contract: contractAddress, method: method, args: args?.map { $0.waasValue }, mode: TransactionMode.native.waasValue @@ -345,7 +347,7 @@ extension WalletClient { ) async throws -> SendTransactionResponse { let prepareResponse = try await signedClient.prepareEthereumTransaction( PrepareEthereumTransactionRequest( - network: network.chainId, + network: network.waasChainId, walletId: walletId, to: request.to, value: request.value, @@ -366,9 +368,9 @@ extension WalletClient { public func callContract( network: Network, - contract: String, + contractAddress: String, method: String, - args: [AbiArg]?, + args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, @@ -382,9 +384,9 @@ extension WalletClient { let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) let prepareResponse = try await signedClient.prepareEthereumContractCall( PrepareEthereumContractCallRequest( - network: network.chainId, + network: network.waasChainId, walletId: walletId, - contract: contract, + contract: contractAddress, method: method, args: args?.map { $0.waasValue }, mode: mode.waasValue @@ -654,8 +656,8 @@ extension WalletClient { return FeeOptionWithBalance( feeOption: feeOption, selection: FeeOptionSelection(feeOption: feeOption, index: UInt32(index)), - available: formatTokenAmount(balance?.rawBalance, decimals: decimals), - availableRaw: balance?.rawBalance, + available: formatTokenAmount(balance?.balance, decimals: decimals), + availableRaw: balance?.balance, decimals: decimals ) } @@ -705,8 +707,8 @@ extension WalletClient { return FeeOptionWithBalance( feeOption: feeOption, selection: FeeOptionSelection(feeOption: feeOption, index: UInt32(index)), - available: formatTokenAmount(balance?.rawBalance, decimals: decimals), - availableRaw: balance?.rawBalance, + available: formatTokenAmount(balance?.balance, decimals: decimals), + availableRaw: balance?.balance, decimals: decimals ) } @@ -819,6 +821,17 @@ extension WalletClient { try requireActiveWalletType(.tron) } + /// Returns `walletAddress` when given; otherwise the active wallet's address, which must be of + /// `walletType`. Verification never sends a wallet ID. + private func verificationWalletAddress(_ walletAddress: String?, walletType: WalletType) throws -> String { + if let walletAddress { + return walletAddress + } + try requireActiveWalletType(walletType) + _ = try requireActiveWalletId() + return try requireActiveWalletAddress() + } + /// Checks the stored wallet type, not the address shape. func requireActiveWalletType(_ type: WalletType) throws { guard let activeWallet else { @@ -886,38 +899,6 @@ private enum FeeBalanceNetwork { case tron(TronNetwork) } -private extension TronBalance { - var rawBalance: String { - switch self { - case .native(let balance): balance.balance - case .fungibleToken(let balance): balance.balance - } - } - - var decimals: Int { - switch self { - case .native(let balance): balance.decimals - case .fungibleToken(let balance): balance.decimals - } - } -} - -private extension SolanaBalance { - var rawBalance: String { - switch self { - case .native(let balance): balance.balance - case .fungibleToken(let balance): balance.balance - } - } - - var decimals: Int { - switch self { - case .native(let balance): balance.decimals - case .fungibleToken(let balance): balance.decimals - } - } -} - private func normalizedAddress(_ address: String?) -> String? { guard let trimmed = address?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { diff --git a/Sources/OMSWallet/Clients/WalletSessions.swift b/Sources/OMSWallet/Clients/WalletSessions.swift index 30add0f..be2097c 100644 --- a/Sources/OMSWallet/Clients/WalletSessions.swift +++ b/Sources/OMSWallet/Clients/WalletSessions.swift @@ -15,16 +15,15 @@ extension WalletClient { return } - guard let storedExpiresAt = OMSWalletSession.parseDate(storedWallet.expiresAt) else { + guard let storedSession = WalletSessionSnapshot( + wallet: storedWallet.wallet, + expiresAt: storedWallet.expiresAt, + auth: storedWallet.auth + ) else { // An unreadable expiry is treated as expired; restore() discards the stored session. _ = credentialSession.restore() return } - let storedSession = WalletSessionSnapshot( - wallet: storedWallet.wallet, - expiresAt: storedExpiresAt, - auth: storedWallet.auth - ) guard !isSessionExpired(storedSession) else { expireStoredSession(storedSession) return @@ -43,7 +42,7 @@ extension WalletClient { } func isSessionExpired(_ session: WalletSessionSnapshot) -> Bool { - currentDate() >= session.expiresAt + currentDate() >= session.expiryDate } private func expireStoredSession(_ session: WalletSessionSnapshot) { @@ -72,15 +71,13 @@ extension WalletClient { } private func currentSessionLocked() -> WalletSessionSnapshot? { - guard let activeWallet, - let expiresAt = OMSWalletSession.parseDate(sessionExpiresAt), - let sessionAuth else { + guard let activeWallet, let sessionAuth else { return nil } return WalletSessionSnapshot( wallet: activeWallet, - expiresAt: expiresAt, + expiresAt: sessionExpiresAt, auth: sessionAuth ) } @@ -138,7 +135,7 @@ extension WalletClient { } func scheduleSessionExpiry(_ session: WalletSessionSnapshot) { - let delay = max(0, session.expiresAt.timeIntervalSince(currentDate())) + let delay = max(0, session.expiryDate.timeIntervalSince(currentDate())) guard delay > 0 else { expireSessionFromTimer(session) return @@ -326,7 +323,7 @@ extension WalletClient { walletId: walletId, grants: Grants(entries: grants.map(\.waasValue)), expiry: expiresAt, - chainId: network.chainId, + chainId: network.waasChainId, sessionId: sessionId ) ) @@ -415,7 +412,7 @@ extension WalletClient { let walletId = try requireActiveWalletId() try requireActiveCredential() let response = try await signedClient.getSessionUsage( - GetSessionUsageRequest(sessionId: sessionId, network: network.chainId) + GetSessionUsageRequest(sessionId: sessionId, network: network.waasChainId) ) try requireSameActiveWalletSession(walletId) return try response.entries.map { entry in diff --git a/Sources/OMSWallet/Models/Auth/PendingWalletSelection.swift b/Sources/OMSWallet/Models/Auth/PendingWalletSelection.swift index ca45b4b..579ef2a 100644 --- a/Sources/OMSWallet/Models/Auth/PendingWalletSelection.swift +++ b/Sources/OMSWallet/Models/Auth/PendingWalletSelection.swift @@ -6,8 +6,8 @@ public final class PendingWalletSelection: @unchecked Sendable { public let wallets: [Wallet] public let credential: WalletCredential - private let selectWalletAction: (String) async throws -> WalletSelectionResult - private let createAndSelectWalletAction: (String?) async throws -> WalletSelectionResult + private let selectWalletAction: (String) async throws -> WalletActivationResult + private let createAndSelectWalletAction: (String?) async throws -> WalletActivationResult private let actionLock = NSLock() private var actionInFlight = false @@ -15,8 +15,8 @@ public final class PendingWalletSelection: @unchecked Sendable { walletType: WalletType, wallets: [Wallet], credential: WalletCredential, - selectWalletAction: @escaping (String) async throws -> WalletSelectionResult, - createAndSelectWalletAction: @escaping (String?) async throws -> WalletSelectionResult + selectWalletAction: @escaping (String) async throws -> WalletActivationResult, + createAndSelectWalletAction: @escaping (String?) async throws -> WalletActivationResult ) { self.walletType = walletType self.wallets = wallets @@ -26,7 +26,7 @@ public final class PendingWalletSelection: @unchecked Sendable { } @discardableResult - public func selectWallet(walletId: String) async throws -> WalletSelectionResult { + public func selectWallet(walletId: String) async throws -> WalletActivationResult { try await runOMSWalletOperation(.pendingWalletSelectionSelectWallet) { guard wallets.contains(where: { $0.id == walletId }) else { throw OMSWalletError.walletSelectionUnavailable() @@ -38,7 +38,7 @@ public final class PendingWalletSelection: @unchecked Sendable { } @discardableResult - public func createAndSelectWallet(reference: String? = nil) async throws -> WalletSelectionResult { + public func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult { try await runOMSWalletOperation(.pendingWalletSelectionCreateAndSelectWallet) { try await runSelectionAction { try await createAndSelectWalletAction(reference) diff --git a/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift b/Sources/OMSWallet/Models/Auth/WalletActivationResult.swift similarity index 76% rename from Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift rename to Sources/OMSWallet/Models/Auth/WalletActivationResult.swift index 96877e8..ab417ae 100644 --- a/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift +++ b/Sources/OMSWallet/Models/Auth/WalletActivationResult.swift @@ -1,7 +1,7 @@ import Foundation @available(macOS 12.0, iOS 15.0, *) -public struct WalletSelectionResult: Sendable { +public struct WalletActivationResult: Sendable { public let wallet: Wallet public init(wallet: Wallet) { diff --git a/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift index 0fd7c88..cefdeb8 100644 --- a/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift +++ b/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift @@ -72,6 +72,97 @@ public enum SolanaBalance: Decodable, Sendable { throw DecodingError.dataCorruptedError(forKey: .assetType, in: container, debugDescription: "Unsupported Solana asset type") } } + + public var network: SolanaNetwork { + switch self { + case .native(let balance): balance.network + case .fungibleToken(let balance): balance.network + } + } + + public var accountAddress: String { + switch self { + case .native(let balance): balance.accountAddress + case .fungibleToken(let balance): balance.accountAddress + } + } + + public var name: String { + switch self { + case .native(let balance): balance.name + case .fungibleToken(let balance): balance.name + } + } + + public var symbol: String { + switch self { + case .native(let balance): balance.symbol + case .fungibleToken(let balance): balance.symbol + } + } + + public var decimals: Int { + switch self { + case .native(let balance): balance.decimals + case .fungibleToken(let balance): balance.decimals + } + } + + public var balance: String { + switch self { + case .native(let balance): balance.balance + case .fungibleToken(let balance): balance.balance + } + } + + public var formattedBalance: String { + switch self { + case .native(let balance): balance.formattedBalance + case .fungibleToken(let balance): balance.formattedBalance + } + } + + public var imageUrl: String? { + switch self { + case .native(let balance): balance.imageUrl + case .fungibleToken(let balance): balance.imageUrl + } + } + + public var metadataUri: String? { + switch self { + case .native(let balance): balance.metadataUri + case .fungibleToken(let balance): balance.metadataUri + } + } + + public var verificationStatus: SolanaVerificationStatus { + switch self { + case .native(let balance): balance.verificationStatus + case .fungibleToken(let balance): balance.verificationStatus + } + } + + public var verificationSource: SolanaVerificationSource { + switch self { + case .native(let balance): balance.verificationSource + case .fungibleToken(let balance): balance.verificationSource + } + } + + public var priceUSD: String? { + switch self { + case .native(let balance): balance.priceUSD + case .fungibleToken(let balance): balance.priceUSD + } + } + + public var balanceUSD: String? { + switch self { + case .native(let balance): balance.balanceUSD + case .fungibleToken(let balance): balance.balanceUSD + } + } } public struct SolanaNetworkError: Codable, Sendable { diff --git a/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift index 7a68675..495ea6e 100644 --- a/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift +++ b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift @@ -121,6 +121,97 @@ public enum TronBalance: Decodable, Sendable { throw DecodingError.dataCorruptedError(forKey: .assetType, in: container, debugDescription: "Unsupported Tron asset type") } } + + public var network: TronNetwork { + switch self { + case .native(let balance): balance.network + case .fungibleToken(let balance): balance.network + } + } + + public var accountAddress: String { + switch self { + case .native(let balance): balance.accountAddress + case .fungibleToken(let balance): balance.accountAddress + } + } + + public var name: String { + switch self { + case .native(let balance): balance.name + case .fungibleToken(let balance): balance.name + } + } + + public var symbol: String { + switch self { + case .native(let balance): balance.symbol + case .fungibleToken(let balance): balance.symbol + } + } + + public var decimals: Int { + switch self { + case .native(let balance): balance.decimals + case .fungibleToken(let balance): balance.decimals + } + } + + public var balance: String { + switch self { + case .native(let balance): balance.balance + case .fungibleToken(let balance): balance.balance + } + } + + public var formattedBalance: String { + switch self { + case .native(let balance): balance.formattedBalance + case .fungibleToken(let balance): balance.formattedBalance + } + } + + public var imageUrl: String? { + switch self { + case .native(let balance): balance.imageUrl + case .fungibleToken(let balance): balance.imageUrl + } + } + + public var metadataUri: String? { + switch self { + case .native(let balance): balance.metadataUri + case .fungibleToken(let balance): balance.metadataUri + } + } + + public var verificationStatus: TronVerificationStatus { + switch self { + case .native(let balance): balance.verificationStatus + case .fungibleToken(let balance): balance.verificationStatus + } + } + + public var verificationSource: String { + switch self { + case .native(let balance): balance.verificationSource + case .fungibleToken(let balance): balance.verificationSource + } + } + + public var priceUSD: String? { + switch self { + case .native(let balance): balance.priceUSD + case .fungibleToken(let balance): balance.priceUSD + } + } + + public var balanceUSD: String? { + switch self { + case .native(let balance): balance.balanceUSD + case .fungibleToken(let balance): balance.balanceUSD + } + } } public struct TronNetworkError: Codable, Sendable { diff --git a/Sources/OMSWallet/Models/OMSWalletError.swift b/Sources/OMSWallet/Models/OMSWalletError.swift index 9ba4831..24ce25a 100644 --- a/Sources/OMSWallet/Models/OMSWalletError.swift +++ b/Sources/OMSWallet/Models/OMSWalletError.swift @@ -15,6 +15,7 @@ public enum OMSWalletErrorCode: String, Sendable { case validationError = "OMS_VALIDATION_ERROR" case storageError = "OMS_STORAGE_ERROR" case attestationVerificationFailed = "OMS_ATTESTATION_VERIFICATION_FAILED" + case walletAddressAlreadyImported = "OMS_WALLET_ADDRESS_ALREADY_IMPORTED" } public enum OMSWalletOperation: String, Sendable { @@ -24,8 +25,8 @@ public enum OMSWalletOperation: String, Sendable { case walletStartEmailAuth = "wallet.startEmailAuth" case walletCompleteEmailAuth = "wallet.completeEmailAuth" case walletSignInWithOidcIdToken = "wallet.signInWithOidcIdToken" - case walletStartOIDCRedirectAuth = "wallet.startOIDCRedirectAuth" - case walletHandleOIDCRedirectCallback = "wallet.handleOIDCRedirectCallback" + case walletStartOIDCRedirectAuth = "wallet.startOidcRedirectAuth" + case walletHandleOIDCRedirectCallback = "wallet.handleOidcRedirectCallback" case walletUseWallet = "wallet.useWallet" case walletCreateWallet = "wallet.createWallet" case walletImportWallet = "wallet.importWallet" @@ -67,8 +68,8 @@ public enum OMSWalletOperation: String, Sendable { } public enum OMSWalletUpstreamService: String, Sendable { - case waas = "Waas" - case indexer = "Indexer" + case waas = "waas" + case indexer = "indexer" } public struct OMSWalletUpstreamError: Equatable, Sendable { @@ -299,6 +300,18 @@ private extension WebRPCError { let upstreamError = toWaasUpstreamError(status: normalizedStatus) let normalizedMessage = normalizedMessage + if kind == .addressAlreadyImported || error == "AddressAlreadyImported" { + return OMSWalletError( + code: .walletAddressAlreadyImported, + message: normalizedMessage, + operation: operation, + status: 409, + retryable: false, + upstreamError: upstreamError, + underlyingError: self + ) + } + if kind == .commitmentConsumed { return OMSWalletError( code: .authCommitmentConsumed, diff --git a/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift index f37ae68..4a4f66b 100644 --- a/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift +++ b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift @@ -3,13 +3,14 @@ import Foundation /// Expiry and auth metadata for the active wallet session. `WalletClient.session` is non-`nil` /// exactly when `WalletClient.activeWallet` is. public struct OMSWalletSession: Equatable, Sendable { - /// Expiration time for the active wallet session. - public let expiresAt: Date + /// Expiration time for the active wallet session, as the ISO-8601 timestamp returned by the + /// wallet API (the same value as `WalletCredential.expiresAt`). + public let expiresAt: String /// Auth metadata for the active wallet session. public let auth: OMSWalletSessionAuth - public init(expiresAt: Date, auth: OMSWalletSessionAuth) { + public init(expiresAt: String, auth: OMSWalletSessionAuth) { self.expiresAt = expiresAt self.auth = auth } @@ -34,9 +35,10 @@ public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { /// while a manual wallet selection was still pending. public let wallet: Wallet? public let session: OMSWalletSession - public let expiredAt: Date + /// The ISO-8601 expiry timestamp of the session that expired. + public let expiredAt: String - public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: Date) { + public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: String) { self.wallet = wallet self.session = session self.expiredAt = expiredAt @@ -46,9 +48,23 @@ public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { /// A point-in-time view of a session; `wallet` is `nil` while a wallet selection is pending. struct WalletSessionSnapshot: Equatable { let wallet: Wallet? - let expiresAt: Date + /// The ISO-8601 expiry exactly as stored and reported publicly. + let expiresAt: String + /// `expiresAt` parsed, used for expiry checks and scheduling. + let expiryDate: Date let auth: OMSWalletSessionAuth + /// Returns `nil` when `expiresAt` is not a valid ISO-8601 timestamp. + init?(wallet: Wallet?, expiresAt: String?, auth: OMSWalletSessionAuth) { + guard let expiresAt, let expiryDate = OMSWalletSession.parseDate(expiresAt) else { + return nil + } + self.wallet = wallet + self.expiresAt = expiresAt + self.expiryDate = expiryDate + self.auth = auth + } + var session: OMSWalletSession { OMSWalletSession(expiresAt: expiresAt, auth: auth) } diff --git a/Sources/OMSWallet/Network.swift b/Sources/OMSWallet/Network.swift index 7dab8fd..9689982 100644 --- a/Sources/OMSWallet/Network.swift +++ b/Sources/OMSWallet/Network.swift @@ -28,7 +28,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { case mainnet case sepolia case polygon - case polygonAmoy = "amoy" + case amoy case arbitrum case arbitrumSepolia = "arbitrum-sepolia" case optimism @@ -42,8 +42,6 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { case avalancheTestnet = "avalanche-testnet" case katana - public static let amoy: Network = .polygonAmoy - public var id: Int { switch self { case .mainnet: @@ -52,7 +50,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { return 11155111 case .polygon: return 137 - case .polygonAmoy: + case .amoy: return 80002 case .arbitrum: return 42161 @@ -81,7 +79,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { } } - public var chainId: String { + var waasChainId: String { String(id) } @@ -94,7 +92,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { case .mainnet, .sepolia, .arbitrum, .arbitrumSepolia, .optimism, .optimismSepolia, .base, .baseSepolia, .arbitrumNova, .katana: return "ETH" - case .polygon, .polygonAmoy: + case .polygon, .amoy: return "POL" case .bsc, .bscTestnet: return "BNB" @@ -111,7 +109,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { return "https://sepolia.etherscan.io" case .polygon: return "https://polygonscan.com" - case .polygonAmoy: + case .amoy: return "https://amoy.polygonscan.com" case .arbitrum: return "https://arbiscan.io" @@ -152,7 +150,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { return "Sepolia" case .polygon: return "Polygon" - case .polygonAmoy: + case .amoy: return "Polygon Amoy" case .arbitrum: return "Arbitrum" @@ -196,6 +194,5 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { public static func findByName(_ name: String) -> Network? { let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return supportedNetworks.first { $0.name.lowercased() == normalized } - ?? (normalized == "polygonamoy" ? .polygonAmoy : nil) } } diff --git a/Sources/OMSWallet/Utils/UnitHelpers.swift b/Sources/OMSWallet/Utils/UnitHelpers.swift index 4d8d4d9..073bd19 100644 --- a/Sources/OMSWallet/Utils/UnitHelpers.swift +++ b/Sources/OMSWallet/Utils/UnitHelpers.swift @@ -6,7 +6,7 @@ public enum UnitConversionError: Error, Equatable { case fractionalComponentExceedsDecimals(value: String, decimals: Int) } -public func parseUnits(value: String, decimals: Int = 18) throws -> String { +public func parseUnits(value: String, decimals: Int) throws -> String { try validate(decimals: decimals) let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -49,7 +49,7 @@ public func parseUnits(value: String, decimals: Int = 18) throws -> String { public func formatUnits( value: String, - decimals: Int = 18 + decimals: Int ) throws -> String { try validate(decimals: decimals) diff --git a/Sources/OMSWallet/WalletImport.swift b/Sources/OMSWallet/WalletImport.swift index 80543f0..927cb32 100644 --- a/Sources/OMSWallet/WalletImport.swift +++ b/Sources/OMSWallet/WalletImport.swift @@ -19,7 +19,7 @@ public enum WalletImportPrivateKey: Sendable { /// A 32-byte secp256k1 private key. case tronBytes(Data) - var walletType: WalletType { + public var walletType: WalletType { switch self { case .ethereum, .ethereumBytes: .ethereum case .solana, .solanaBytes: .solana diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift index 4dffdd7..42ec0ef 100644 --- a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -56,7 +56,7 @@ private func storedRecordJSON(version: Int = 2, walletJSON: String) -> String { ) #expect(result.wallet == expected) #expect(fixture.client.activeWallet == expected) - #expect(fixture.client.session?.expiresAt == OMSWalletSession.parseDate("2099-01-01T00:00:00Z")) + #expect(fixture.client.session?.expiresAt == "2099-01-01T00:00:00Z") #expect(fixture.client.session?.auth == .email(OMSWalletEmailSessionAuth(email: "user@example.com"))) #expect(try fixture.storedCredentials()?.wallet == expected) @@ -227,6 +227,6 @@ func TestDiscardsStoredSessionsWithInvalidWallets(walletJSON: String) throws { let event = try #require(expiredEvent) #expect(event.wallet == nil) #expect(event.session.auth.email == "user@example.com") - #expect(event.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(event.expiredAt == "2026-01-01T00:00:00Z") #expect(fixture.transport.requestCount(for: WaasAPI.UseWallet.urlPath) == 0) } diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index 9ae39f3..c3c9cdb 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -7,7 +7,7 @@ import Testing .mainnet, .sepolia, .polygon, - .polygonAmoy, + .amoy, .arbitrum, .arbitrumSepolia, .optimism, @@ -25,9 +25,12 @@ import Testing #expect(Network.findById(8453) == .base) #expect(Network.findById(747474) == .katana) #expect(Network.findByName("optimism-sepolia") == .optimismSepolia) - #expect(Network.findByName("polygonamoy") == .polygonAmoy) + #expect(Network.findByName("amoy") == .amoy) + #expect(Network.findByName(" AMOY ") == .amoy) + #expect(Network.findByName("polygonamoy") == nil) + #expect(Network.findByName("polygonAmoy") == nil) #expect(Network(rawValue: "arbitrum-sepolia") == .arbitrumSepolia) - #expect(Network(rawValue: "amoy") == .polygonAmoy) + #expect(Network(rawValue: "amoy") == .amoy) #expect(Network.polygon.displayName == "Polygon") #expect(Network.polygon.description == "Polygon") @@ -35,8 +38,10 @@ import Testing #expect(Network.polygon.name == "polygon") #expect(Network.polygon.nativeTokenSymbol == "POL") #expect(Network.polygon.explorerUrl == "https://polygonscan.com") - #expect(Network.polygonAmoy.name == "amoy") - #expect(Network.amoy == .polygonAmoy) + #expect(Network.amoy.name == "amoy") + #expect(Network.amoy.id == 80002) + #expect(Network.amoy.displayName == "Polygon Amoy") + #expect(Network.findById(80002) == .amoy) } @Test func TestPublishableKeyRoutingDerivesProjectAndApiUrls() throws { @@ -208,6 +213,27 @@ import Testing } #expect(token.mintAddress == "usdc-mint") #expect(result.errors.first?.network == .devnet) + + let native = result.balances[0] + let fungible = result.balances[1] + #expect(native.network == .mainnet) + #expect(native.accountAddress == "solana-wallet") + #expect(native.name == "Solana") + #expect(native.symbol == "SOL") + #expect(native.decimals == 9) + #expect(native.balance == "4679287") + #expect(native.formattedBalance == "0.004679287") + #expect(native.imageUrl == nil) + #expect(native.metadataUri == nil) + #expect(native.verificationStatus == .unknown) + #expect(native.verificationSource == SolanaVerificationSource.none) + #expect(native.priceUSD == nil) + #expect(native.balanceUSD == nil) + #expect(fungible.symbol == "USDC") + #expect(fungible.decimals == 6) + #expect(fungible.balance == "4208117429") + #expect(fungible.verificationStatus == .verified) + #expect(fungible.verificationSource == .jupiter) } @Test func TestGetTronBalancesUsesTronGatewayAndDecodesStrictAssets() async throws { @@ -319,6 +345,22 @@ import Testing #expect(native.metadataUri == nil) #expect(native.priceUSD == "0.27") #expect(native.balanceUSD == "265.44") + let tokenBalance = result.balances[0] + let nativeBalance = result.balances[1] + #expect(tokenBalance.network == .nile) + #expect(tokenBalance.accountAddress == wallet) + #expect(tokenBalance.name == "Tether USD") + #expect(tokenBalance.symbol == "USDT") + #expect(tokenBalance.decimals == 6) + #expect(tokenBalance.balance == "999000000") + #expect(tokenBalance.formattedBalance == "999") + #expect(tokenBalance.verificationStatus == .unknown) + #expect(tokenBalance.verificationSource == "none") + #expect(nativeBalance.symbol == "TRX") + #expect(nativeBalance.imageUrl == nil) + #expect(nativeBalance.metadataUri == nil) + #expect(nativeBalance.priceUSD == "0.27") + #expect(nativeBalance.balanceUSD == "265.44") #expect(result.errors.map(\.network) == [.mainnet]) #expect(result.errors.map(\.reason) == ["RPC unavailable"]) } diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 44bb066..876e91a 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -195,6 +195,43 @@ private func isOidcAuth( #expect(isEmailAuth(fixture.client.session?.auth)) } +@Test func TestWalletSessionLifetimeConstantsDriveDefaultAndLimit() async throws { + #expect(WalletClient.defaultSessionLifetimeSeconds == 604_800) + #expect(WalletClient.maxSessionLifetimeSeconds == 2_592_000) + + let fixture = makeMockWalletClient() + try fixture.transport.enqueue( + CommitVerifierResponse(verifier: "verifier", loginHint: "", challenge: "challenge"), + for: WaasAPI.CommitVerifier.urlPath + ) + try fixture.transport.enqueue( + completeAuthResponse(wallets: [testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111")]), + for: WaasAPI.CompleteAuth.urlPath + ) + try fixture.transport.enqueue( + UseWalletResponse(wallet: testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111")), + for: WaasAPI.UseWallet.urlPath + ) + + try await fixture.client.startEmailAuth(email: "user@example.com") + _ = try await fixture.client.completeEmailAuth(code: "123456") + let completeAuthRequest = try fixture.transport.decodedRequest( + CompleteAuthRequest.self, + for: WaasAPI.CompleteAuth.urlPath + ) + #expect(completeAuthRequest.lifetime == WalletClient.defaultSessionLifetimeSeconds) + + do { + try await fixture.client.startEmailAuth( + email: "user@example.com", + sessionLifetimeSeconds: WalletClient.maxSessionLifetimeSeconds + 1 + ) + Issue.record("Expected a validation error") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + } +} + @Test func TestWalletStartEmailAuthRejectsInvalidSessionLifetimeBeforeRequestOrSignOut() async throws { let fixture = makeMockWalletClient() let wallet = testWallet(id: "wallet-existing", address: "0x1111111111111111111111111111111111111111") @@ -335,9 +372,9 @@ private func isOidcAuth( let storedCredentials = try fixture.storedCredentials() #expect(expiredEvent?.wallet?.address == wallet.address) - #expect(expiredEvent?.session.expiresAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(expiredEvent?.session.expiresAt == expiresAt) #expect(isEmailAuth(expiredEvent?.session.auth)) - #expect(expiredEvent?.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(expiredEvent?.expiredAt == expiresAt) #expect(secondExpiredEvent?.wallet?.address == wallet.address) #expect(canceledExpiredEvent == nil) #expect(fixture.client.session == nil) @@ -372,7 +409,7 @@ private func isOidcAuth( #expect(fixture.client.session == nil) #expect(replayedEvent?.wallet?.address == storedCredentials.wallet.address) #expect(isEmailAuth(replayedEvent?.session.auth)) - #expect(replayedEvent?.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(replayedEvent?.expiredAt == "2026-01-01T00:00:00Z") #expect(try fixture.storedCredentials()?.wallet.id == storedCredentials.wallet.id) #expect(fixture.signer.clearCallCount == 1) } @@ -1915,44 +1952,42 @@ private func waitForSessionExpiredEvent( try await fixture.client.revokeAccess(credentialId: "credential-1") } await expectNoAuthenticatedWalletSession { - try await fixture.client.signMessage(network: .polygonAmoy, message: "hello") + try await fixture.client.signMessage(network: .amoy, message: "hello") } await expectNoAuthenticatedWalletSession { - try await fixture.client.signTypedData(network: .polygonAmoy, typedData: .object([:])) + try await fixture.client.signTypedData(network: .amoy, typedData: .object([:])) } await expectNoAuthenticatedWalletSession { try await fixture.client.isValidMessageSignature( - network: .polygonAmoy, - walletAddress: "0xwallet", + network: .amoy, message: "hello", signature: "0xsig" ) } await expectNoAuthenticatedWalletSession { try await fixture.client.isValidTypedDataSignature( - network: .polygonAmoy, - walletAddress: "0xwallet", + network: .amoy, typedData: .object([:]), signature: "0xsig" ) } await expectNoAuthenticatedWalletSession { try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0" ) } await expectNoAuthenticatedWalletSession { try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) } await expectNoAuthenticatedWalletSession { try await fixture.client.callContract( - network: .polygonAmoy, - contract: "0xcontract", + network: .amoy, + contractAddress: "0xcontract", method: "mint", args: nil ) @@ -1977,7 +2012,7 @@ private func waitForSessionExpiredEvent( walletIdOnlyFixture.client.walletId = "wallet-main" await expectNoAuthenticatedWalletSession { try await walletIdOnlyFixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0", selectFeeOption: .firstAvailable @@ -1985,8 +2020,8 @@ private func waitForSessionExpiredEvent( } await expectNoAuthenticatedWalletSession { try await walletIdOnlyFixture.client.callContract( - network: .polygonAmoy, - contract: "0xcontract", + network: .amoy, + contractAddress: "0xcontract", method: "mint", args: nil, selectFeeOption: .firstAvailable @@ -2378,7 +2413,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0" ) @@ -2464,7 +2499,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0", selectFeeOption: .firstAvailable @@ -2523,7 +2558,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0", selectFeeOption: .firstAvailable @@ -2633,7 +2668,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0", mode: .native), selectFeeOption: .custom { feeOptions in #expect(feeOptions.count == 2) @@ -2686,7 +2721,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .custom { _ in nil } ) @@ -2727,7 +2762,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .custom { feeOptions in #expect(feeOptions.isEmpty) @@ -2768,7 +2803,7 @@ private func waitForSessionExpiredEvent( ) let result = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .firstAvailable, waitForStatus: false @@ -2796,7 +2831,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .custom { feeOptions in #expect(feeOptions.isEmpty) @@ -2836,8 +2871,8 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.callContract( - network: .polygonAmoy, - contract: "0xcontract", + network: .amoy, + contractAddress: "0xcontract", method: "mint", args: [AbiArg(type: "address", value: .string("0xrecipient"))], mode: .native @@ -2881,7 +2916,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), waitForStatus: false ) @@ -2914,8 +2949,8 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.callContract( - network: .polygonAmoy, - contract: "0xcontract", + network: .amoy, + contractAddress: "0xcontract", method: "mint", args: [AbiArg(type: "address", value: .string("0xrecipient"))], waitForStatus: false @@ -2953,7 +2988,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), statusPolling: TransactionStatusPollingOptions( timeoutMs: 0 @@ -2994,7 +3029,7 @@ private func waitForSessionExpiredEvent( ) let result = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), statusPolling: TransactionStatusPollingOptions(timeoutMs: 0) ) @@ -3029,7 +3064,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), statusPolling: options ) @@ -3068,7 +3103,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) @@ -3104,7 +3139,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) @@ -3140,7 +3175,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) @@ -3169,7 +3204,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) #expect(Bool(false), "Expected no fee options error") @@ -3553,7 +3588,7 @@ final class MockIndexerBackend: @unchecked Sendable { let fallbackChainId = request.chainIds.first ?? nativeBalance?.chainId ?? tokenBalances.first?.chainId - ?? Int64(Network.polygonAmoy.id) + ?? Int64(Network.amoy.id) let response = GatewayBalancesResponse( page: TokenBalancesPage(page: 0, pageSize: 40, more: false), nativeBalances: nativeBalance.map { diff --git a/Tests/OMSWalletTests/PublicErrorContractsTests.swift b/Tests/OMSWalletTests/PublicErrorContractsTests.swift index 5734e1b..4b09743 100644 --- a/Tests/OMSWalletTests/PublicErrorContractsTests.swift +++ b/Tests/OMSWalletTests/PublicErrorContractsTests.swift @@ -90,6 +90,61 @@ import Testing ) } +@Test func TestPublicErrorContractsWalletImportAddressAlreadyImported() async throws { + let importTransport = MockWaasTransport() + let fixture = makeMockWalletClient( + walletImportClient: WaasClient(baseURL: "https://wallet-import.test", transport: importTransport) + ) + fixture.client.walletId = "wallet-main" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") + fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + importTransport.enqueueRawHTTPError( + statusCode: 400, + body: Data( + """ + {"error":"AddressAlreadyImported","code":7313,"msg":"Address already imported","status":400} + """.utf8 + ), + for: WaasAPI.ImportWallet.urlPath + ) + + await expectPublicError( + try await fixture.client.importEncryptedWallet( + walletType: .ethereum, + keyMaterial: EncryptedWalletImportKeyMaterial( + keyId: "recipient-key", + cipherSuite: .p256Sha256Aes256Gcm, + encapsulatedKey: Data([1, 2, 3]).base64EncodedString(), + ciphertext: Data([4, 5, 6]).base64EncodedString() + ) + ), + equals: error( + code: .walletAddressAlreadyImported, + operation: .walletImportEncryptedWallet, + message: "Address already imported", + status: 409, + retryable: false, + upstreamError: upstream( + service: .waas, + name: "AddressAlreadyImported", + code: "7313", + message: "Address already imported", + status: 400 + ) + ) + ) + #expect(fixture.client.activeWallet?.id == "wallet-main") +} + +@Test func TestPublicErrorContractsWireValues() { + #expect(OMSWalletErrorCode.walletAddressAlreadyImported.rawValue == "OMS_WALLET_ADDRESS_ALREADY_IMPORTED") + #expect(OMSWalletOperation.walletStartOIDCRedirectAuth.rawValue == "wallet.startOidcRedirectAuth") + #expect(OMSWalletOperation.walletHandleOIDCRedirectCallback.rawValue == "wallet.handleOidcRedirectCallback") + #expect(OMSWalletUpstreamService.waas.rawValue == "waas") + #expect(OMSWalletUpstreamService.indexer.rawValue == "indexer") +} + @Test func TestPublicErrorContractsWaasHttpAndBadResponsesHaveUpstreamDetails() async throws { let httpFixture = makeRestoredWalletClient() httpFixture.transport.enqueueRawHTTPError( diff --git a/Tests/OMSWalletTests/RequestsTests.swift b/Tests/OMSWalletTests/RequestsTests.swift index be6b13d..855eef2 100644 --- a/Tests/OMSWalletTests/RequestsTests.swift +++ b/Tests/OMSWalletTests/RequestsTests.swift @@ -98,13 +98,13 @@ import Testing } @Test func TestOMSWalletSessionParsesExpiresAt() throws { - let expiresAt = try #require(OMSWalletSession.parseDate("2026-01-01T00:00:00Z")) let session = OMSWalletSession( - expiresAt: expiresAt, + expiresAt: "2026-01-01T00:00:00Z", auth: .email(OMSWalletEmailSessionAuth(email: "user@example.com")) ) - #expect(session.expiresAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(session.expiresAt == "2026-01-01T00:00:00Z") + #expect(OMSWalletSession.parseDate(session.expiresAt) == Date(timeIntervalSince1970: 1_767_225_600)) #expect(OMSWalletSession.parseDate("2026-01-01T00:00:00.500Z") == Date(timeIntervalSince1970: 1_767_225_600.5)) #expect(OMSWalletSession.parseDate("not-a-date") == nil) #expect(session.auth.email == "user@example.com") diff --git a/Tests/OMSWalletTests/SignatureVerificationTests.swift b/Tests/OMSWalletTests/SignatureVerificationTests.swift new file mode 100644 index 0000000..71ec39c --- /dev/null +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing +@testable import OMSWallet + +private let ethereumAddress = "0x1111111111111111111111111111111111111111" +private let solanaAddress = "3gFktQX6vki5M2DzN8Y1ESPUJ4fJ8o6hVQWf8vYvPypD" +private let tronAddress = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" +private let typedData: JSONValue = .object(["primaryType": .string("Mail")]) + +enum SignatureVerificationMethod: CaseIterable, CustomTestStringConvertible { + case evmMessage + case evmTypedData + case solanaMessage + case tronMessage + case tronTypedData + + var testDescription: String { "\(self)" } + + var walletType: WalletType { + switch self { + case .evmMessage, .evmTypedData: .ethereum + case .solanaMessage: .solana + case .tronMessage, .tronTypedData: .tron + } + } + + var address: String { + switch walletType { + case .ethereum: ethereumAddress + case .solana: solanaAddress + default: tronAddress + } + } + + var operation: OMSWalletOperation { + switch self { + case .evmMessage: .walletIsValidMessageSignature + case .evmTypedData: .walletIsValidTypedDataSignature + case .solanaMessage: .walletIsValidSolanaMessageSignature + case .tronMessage: .walletIsValidTronMessageSignature + case .tronTypedData: .walletIsValidTronTypedDataSignature + } + } + + var isTypedData: Bool { + self == .evmTypedData || self == .tronTypedData + } + + var path: String { + isTypedData + ? WaasPublicAPI.IsValidTypedDataSignature.urlPath + : WaasPublicAPI.IsValidMessageSignature.urlPath + } + + func enqueueValidResponse(_ transport: MockWaasTransport) throws { + if isTypedData { + try transport.enqueue(IsValidTypedDataSignatureResponse(isValid: true), for: path) + } else { + try transport.enqueue(IsValidMessageSignatureResponse(isValid: true), for: path) + } + } + + func call(_ client: WalletClient, walletAddress: String?) async throws -> Bool { + switch self { + case .evmMessage: + try await client.isValidMessageSignature( + network: .polygon, + walletAddress: walletAddress, + message: "hello", + signature: "0xsig" + ) + case .evmTypedData: + try await client.isValidTypedDataSignature( + network: .polygon, + walletAddress: walletAddress, + typedData: typedData, + signature: "0xsig" + ) + case .solanaMessage: + try await client.isValidSolanaMessageSignature( + walletAddress: walletAddress, + message: "hello", + signature: "0xsig" + ) + case .tronMessage: + try await client.isValidTronMessageSignature( + walletAddress: walletAddress, + message: "hello", + signature: "0xsig" + ) + case .tronTypedData: + try await client.isValidTronTypedDataSignature( + walletAddress: walletAddress, + typedData: typedData, + signature: "0xsig" + ) + } + } + + func expectedBody(walletAddress: String) -> NSDictionary { + var body: [String: Any] = ["walletAddress": walletAddress, "signature": "0xsig"] + switch self { + case .evmMessage, .evmTypedData: + body["network"] = "137" + body["networkFamily"] = "evm" + case .solanaMessage: + body["networkFamily"] = "solana" + case .tronMessage, .tronTypedData: + body["networkFamily"] = "tron" + } + if isTypedData { + body["typedData"] = ["primaryType": "Mail"] + } else { + body["message"] = "hello" + } + return body as NSDictionary + } +} + +private func activate(_ fixture: MockWalletClientFixture, type: WalletType, address: String) { + fixture.client.walletId = "wallet-active" + fixture.client.activeWallet = Wallet(id: "wallet-active", type: type, address: address, keyOrigin: .enclave) + fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) +} + +@Test(arguments: SignatureVerificationMethod.allCases) +func TestSignatureVerificationWithAddressWorksWhileSignedOut(_ method: SignatureVerificationMethod) async throws { + let fixture = makeMockWalletClient() + try method.enqueueValidResponse(fixture.transport) + + let isValid = try await method.call(fixture.client, walletAddress: method.address) + + let body = try fixture.transport.requestObject(for: method.path) + #expect(isValid) + #expect(body == method.expectedBody(walletAddress: method.address)) + #expect(body["walletId"] == nil) +} + +@Test(arguments: SignatureVerificationMethod.allCases) +func TestSignatureVerificationWithAddressIgnoresActiveWalletType(_ method: SignatureVerificationMethod) async throws { + let fixture = makeMockWalletClient() + let otherType: WalletType = method.walletType == .solana ? .tron : .solana + activate(fixture, type: otherType, address: otherType == .solana ? solanaAddress : tronAddress) + try method.enqueueValidResponse(fixture.transport) + + _ = try await method.call(fixture.client, walletAddress: method.address) + + let body = try fixture.transport.requestObject(for: method.path) + #expect(body == method.expectedBody(walletAddress: method.address)) +} + +@Test(arguments: SignatureVerificationMethod.allCases) +func TestSignatureVerificationWithoutAddressUsesActiveWalletAddress(_ method: SignatureVerificationMethod) async throws { + let fixture = makeMockWalletClient() + activate(fixture, type: method.walletType, address: method.address) + try method.enqueueValidResponse(fixture.transport) + + let isValid = try await method.call(fixture.client, walletAddress: nil) + + let body = try fixture.transport.requestObject(for: method.path) + #expect(isValid) + #expect(body == method.expectedBody(walletAddress: method.address)) + #expect(body["walletId"] == nil) +} + +@Test(arguments: SignatureVerificationMethod.allCases) +func TestSignatureVerificationWithoutAddressRequiresSession(_ method: SignatureVerificationMethod) async throws { + let fixture = makeMockWalletClient() + + do { + _ = try await method.call(fixture.client, walletAddress: nil) + Issue.record("Expected a missing-session error") + } catch let error as OMSWalletError { + #expect(error.code == .sessionMissing) + #expect(error.operation == method.operation) + } + #expect(fixture.transport.requestCount(for: method.path) == 0) +} + +@Test(arguments: SignatureVerificationMethod.allCases) +func TestSignatureVerificationWithoutAddressRejectsMismatchedActiveWallet( + _ method: SignatureVerificationMethod +) async throws { + let fixture = makeMockWalletClient() + let otherType: WalletType = method.walletType == .ethereum ? .solana : .ethereum + activate(fixture, type: otherType, address: otherType == .ethereum ? ethereumAddress : solanaAddress) + + do { + _ = try await method.call(fixture.client, walletAddress: nil) + Issue.record("Expected a validation error") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + #expect(error.operation == method.operation) + } + #expect(fixture.transport.requestCount(for: method.path) == 0) +} diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift index 0d7922d..d14adfb 100644 --- a/Tests/OMSWalletTests/TronWalletTests.swift +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -126,7 +126,7 @@ private func expectValidationError( let result = try await fixture.client.callTronContract( network: .mainnet, - contract: tronUsdt, + contractAddress: tronUsdt, method: "transfer", args: [ AbiArg(type: "address", value: .string(tronRecipient)), @@ -157,7 +157,7 @@ func TestContractCallsRejectNonBareMethodNamesBeforeAnyRequest(method: String) a await expectValidationError(.walletCallTronContract) { try await tron.client.callTronContract( network: .nile, - contract: tronUsdt, + contractAddress: tronUsdt, method: method, args: [AbiArg(type: "uint256", value: .string("1"))] ) @@ -167,7 +167,7 @@ func TestContractCallsRejectNonBareMethodNamesBeforeAnyRequest(method: String) a await expectValidationError(.walletCallContract) { try await ethereum.client.callContract( network: .polygon, - contract: "0x1111111111111111111111111111111111111111", + contractAddress: "0x1111111111111111111111111111111111111111", method: method, args: nil ) @@ -184,7 +184,7 @@ func TestContractCallsRejectNonBareMethodNamesBeforeAnyRequest(method: String) a try fixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) let result = try await fixture.client.callTronContract( network: .nile, - contract: tronUsdt, + contractAddress: tronUsdt, method: method, waitForStatus: false ) @@ -335,7 +335,7 @@ func TestContractCallsRejectNonBareMethodNamesBeforeAnyRequest(method: String) a _ = try await fixture.client.callTronContract( network: .nile, - contract: tronUsdt, + contractAddress: tronUsdt, method: "transfer", args: [ AbiArg(type: "address", value: .string(tronRecipient)), @@ -446,7 +446,7 @@ func TestTronOperationsRejectNonTronWalletsBeforeAnyRequest(type: WalletType, ad try await fixture.client.sendTronTransaction(network: .nile, to: tronRecipient, value: "1") } await expectValidationError(.walletCallTronContract) { - try await fixture.client.callTronContract(network: .nile, contract: tronUsdt, method: "transfer") + try await fixture.client.callTronContract(network: .nile, contractAddress: tronUsdt, method: "transfer") } await expectValidationError(.walletSignTronMessage) { try await fixture.client.signTronMessage(message: "hello") @@ -465,7 +465,7 @@ func TestTronOperationsRejectNonTronWalletsBeforeAnyRequest(type: WalletType, ad try await fixture.client.sendTransaction(network: .polygon, to: tronWalletHex, value: "1") } await expectValidationError(.walletCallContract) { - try await fixture.client.callContract(network: .polygon, contract: tronWalletHex, method: "transfer", args: nil) + try await fixture.client.callContract(network: .polygon, contractAddress: tronWalletHex, method: "transfer", args: nil) } await expectValidationError(.walletSignMessage) { try await fixture.client.signMessage(network: .polygon, message: "hello") diff --git a/docs/error-contracts.md b/docs/error-contracts.md index 8f2e4c2..feb835f 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -18,6 +18,9 @@ should be present, and which tests own the contract. - `upstreamError` is normalized diagnostic detail from a remote OMS service response, malformed remote response, or transport failure. Use it for logging and service-specific troubleshooting, not primary app branching. + `upstreamError.service` is `.waas` or `.indexer` (raw values `"waas"` and + `"indexer"`). `upstreamError.code` is a string; numeric WebRPC codes are + stringified (e.g. `"7313"`). - `underlyingError` is Swift-local diagnostic context. It is present when the SDK wraps a lower-level Swift error such as an internal transaction-flow error, HTTP transport failures, `URLError`, generated service-client failures, or a decoding error. @@ -63,8 +66,10 @@ should be present, and which tests own the contract. | `handleOIDCRedirectCallback` | URL or state does not match the pending redirect | `.notOIDCRedirectCallback` result | Ignore the unrelated callback; the pending redirect remains available | Absent | `MockWalletTests.swift` | | `omsWallet.wallet.startOIDCRedirectAuth`, `handleOIDCRedirectCallback` | Local OIDC redirect-state persistence or restore failure | Thrown `OMSWalletError` with `.storageError` | Retry OIDC auth after resolving the local storage issue | Absent | `PublicErrorContractsTests.swift` | | Protected wallet methods: `getIdToken`, signing and transaction methods, wallet import, `getTransactionStatus`, access authorization/usage/listing/revocation | Missing or expired local session | `.sessionMissing` or `.sessionExpired` | Authenticate again or recover local session; no remote request was made | Absent | `PublicErrorContractsTests.swift` | +| `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called without `walletAddress` | No active wallet, or the active wallet belongs to another chain family | `.sessionMissing` (or `.sessionExpired`), or `.validationError` | Sign in with a wallet of the method's family, or pass `walletAddress`; with an address no session is required. No remote request was made | Absent | `SignatureVerificationTests.swift` | | Wallet signing, transactions, import, and owner access methods | SDK-local validation, wallet-family mismatch (checked against the active wallet's stored type), non-bare contract `method` name, or fee-selection failure | `.validationError` | Correct parameters, select the required wallet family, or fix local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift`, `WalletImportTests.swift`, `MockWalletTests.swift`, `TronWalletTests.swift` | | Wallet auth, selection, creation, and import methods | Successful WaaS response with an Ethereum wallet address that is not `0x` plus 40 hex digits, or an unreadable credential `expiresAt` | `.invalidResponse` | Treat the payload as unusable; the wallet is not activated | Absent | `ActiveWalletSessionTests.swift` | +| `omsWallet.wallet.importWallet`, `importEncryptedWallet` | Address already imported (WaaS `AddressAlreadyImported`, code `7313`) | `.walletAddressAlreadyImported` (`OMS_WALLET_ADDRESS_ALREADY_IMPORTED`), `status == 409`, `retryable == false`; the active wallet is unchanged | Select the existing wallet or use a different key | Present | `PublicErrorContractsTests.swift` | | `omsWallet.wallet.getWalletImportRecipientKey`, `importWallet`, `importEncryptedWallet` | Recipient-key attestation is missing, stale, malformed, untrusted, or does not match the request/response | `.attestationVerificationFailed` with the wallet-import operation | Do not encrypt or submit key material; retry only after confirming the SDK's managed PCR0 trust policy and WaaS environment | Absent | `WalletImportTests.swift` | | `omsWallet.wallet.isValidMessageSignature`, `isValidSolanaMessageSignature`, `isValidTypedDataSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` | WaaS validation backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with the validation operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift` | | `omsWallet.wallet.sendTransaction`, `sendSolanaTransfer`, `sendTronTransaction`, `callContract`, `callTronContract` | Execute request fails after prepare | `.transactionExecutionUnconfirmed`, `operation == .walletExecute`, `retryable == false`, `txnId` | Do not blindly resend the write; preserve `txnId` and upstream detail for diagnostics | Present when execute crossed a transport/upstream boundary | `PublicErrorContractsTests.swift` | diff --git a/scripts/api-presentation.json b/scripts/api-presentation.json index 8776482..250272e 100644 --- a/scripts/api-presentation.json +++ b/scripts/api-presentation.json @@ -17,10 +17,13 @@ "path": "WalletClient.activeWallet" }, { - "path": "WalletClient.walletId" + "path": "WalletClient.session" }, { - "path": "WalletClient.session" + "path": "WalletClient.defaultSessionLifetimeSeconds" + }, + { + "path": "WalletClient.maxSessionLifetimeSeconds" }, { "path": "WalletClient.addSessionExpiredObserver(_:)" @@ -35,10 +38,10 @@ "path": "WalletClient.signInWithOidcIdToken(idToken:issuer:audience:walletType:walletSelection:sessionLifetimeSeconds:provider:providerLabel:)" }, { - "path": "WalletClient.startOIDCRedirectAuth(provider:walletType:loginHint:authorizeParams:walletSelection:sessionLifetimeSeconds:)" + "path": "WalletClient.startOIDCRedirectAuth(provider:walletType:walletSelection:sessionLifetimeSeconds:loginHint:authorizeParams:)" }, { - "path": "WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:loginHint:walletSelection:sessionLifetimeSeconds:)" + "path": "WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:walletSelection:sessionLifetimeSeconds:loginHint:)" }, { "path": "WalletClient.handleOIDCRedirectCallback(_:walletSelection:sessionLifetimeSeconds:)" @@ -101,7 +104,7 @@ "path": "PendingWalletSelection" }, { - "path": "WalletSelectionResult" + "path": "WalletActivationResult" }, { "path": "Wallet" @@ -244,10 +247,10 @@ "path": "WalletClient.sendTronTransaction(network:to:value:data:selectFeeOption:waitForStatus:statusPolling:)" }, { - "path": "WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)" + "path": "WalletClient.callContract(network:contractAddress:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)" }, { - "path": "WalletClient.callTronContract(network:contract:method:args:selectFeeOption:waitForStatus:statusPolling:)" + "path": "WalletClient.callTronContract(network:contractAddress:method:args:selectFeeOption:waitForStatus:statusPolling:)" }, { "path": "WalletClient.getTransactionStatus(txnId:)" diff --git a/scripts/public-api-baseline.txt b/scripts/public-api-baseline.txt index 02da920..8a3608f 100644 --- a/scripts/public-api-baseline.txt +++ b/scripts/public-api-baseline.txt @@ -276,6 +276,7 @@ conformance Wallet Swift.Decodable conformance Wallet Swift.Encodable conformance Wallet Swift.Equatable conformance Wallet Swift.Sendable +conformance WalletActivationResult Swift.Sendable conformance WalletClient Swift.Sendable conformance WalletCredential Swift.Decodable conformance WalletCredential Swift.Encodable @@ -300,7 +301,6 @@ conformance WalletSelectionBehavior Swift.Equatable conformance WalletSelectionBehavior Swift.Hashable conformance WalletSelectionBehavior Swift.RawRepresentable conformance WalletSelectionBehavior Swift.Sendable -conformance WalletSelectionResult Swift.Sendable conformance WalletType Swift.Decodable conformance WalletType Swift.Encodable conformance WalletType Swift.Equatable @@ -369,6 +369,7 @@ symbol swift.enum.case JSONValue.number(_:) case number(Double) symbol swift.enum.case JSONValue.object(_:) case object([String : JSONValue]) symbol swift.enum.case JSONValue.string(_:) case string(String) symbol swift.enum.case JSONValue.unsignedInteger(_:) case unsignedInteger(UInt64) +symbol swift.enum.case Network.amoy case amoy symbol swift.enum.case Network.arbitrum case arbitrum symbol swift.enum.case Network.arbitrumNova case arbitrumNova symbol swift.enum.case Network.arbitrumSepolia case arbitrumSepolia @@ -383,7 +384,6 @@ symbol swift.enum.case Network.mainnet case mainnet symbol swift.enum.case Network.optimism case optimism symbol swift.enum.case Network.optimismSepolia case optimismSepolia symbol swift.enum.case Network.polygon case polygon -symbol swift.enum.case Network.polygonAmoy case polygonAmoy symbol swift.enum.case Network.sepolia case sepolia symbol swift.enum.case OIDCAuthMode.authCode case authCode symbol swift.enum.case OIDCAuthMode.authCodePKCE case authCodePKCE @@ -401,6 +401,7 @@ symbol swift.enum.case OMSWalletErrorCode.storageError case storageError symbol swift.enum.case OMSWalletErrorCode.transactionExecutionUnconfirmed case transactionExecutionUnconfirmed symbol swift.enum.case OMSWalletErrorCode.transactionStatusLookupFailed case transactionStatusLookupFailed symbol swift.enum.case OMSWalletErrorCode.validationError case validationError +symbol swift.enum.case OMSWalletErrorCode.walletAddressAlreadyImported case walletAddressAlreadyImported symbol swift.enum.case OMSWalletErrorCode.walletSelectionInFlight case walletSelectionInFlight symbol swift.enum.case OMSWalletErrorCode.walletSelectionStale case walletSelectionStale symbol swift.enum.case OMSWalletErrorCode.walletSelectionUnavailable case walletSelectionUnavailable @@ -515,8 +516,8 @@ symbol swift.enum.case WalletType.ethereum case ethereum symbol swift.enum.case WalletType.solana case solana symbol swift.enum.case WalletType.tron case tron symbol swift.enum.case WalletType.unknown(_:) case unknown(String) -symbol swift.func formatUnits(value:decimals:) func formatUnits(value: String, decimals: Int = 18) throws -> String -symbol swift.func parseUnits(value:decimals:) func parseUnits(value: String, decimals: Int = 18) throws -> String +symbol swift.func formatUnits(value:decimals:) func formatUnits(value: String, decimals: Int) throws -> String +symbol swift.func parseUnits(value:decimals:) func parseUnits(value: String, decimals: Int) throws -> String symbol swift.init AbiArg.init(from:) init(from decoder: any Decoder) throws symbol swift.init AbiArg.init(type:value:) init(type: String, value: JSONValue) symbol swift.init AccessGrantPage.init(grants:page:) init(grants: [AccessGrant], page: Page? = nil) @@ -556,9 +557,9 @@ symbol swift.init OMSWalletOidcSessionAuth.init(flow:issuer:provider:providerLab symbol swift.init OMSWalletOidcSessionAuth.init(from:) init(from decoder: Decoder) throws symbol swift.init OMSWalletOidcSessionAuthFlow.init(rawValue:) init?(rawValue: String) symbol swift.init OMSWalletOperation.init(rawValue:) init?(rawValue: String) -symbol swift.init OMSWalletSession.init(expiresAt:auth:) init(expiresAt: Date, auth: OMSWalletSessionAuth) +symbol swift.init OMSWalletSession.init(expiresAt:auth:) init(expiresAt: String, auth: OMSWalletSessionAuth) symbol swift.init OMSWalletSessionAuth.init(from:) init(from decoder: Decoder) throws -symbol swift.init OMSWalletSessionExpiredEvent.init(wallet:session:expiredAt:) init(wallet: Wallet?, session: OMSWalletSession, expiredAt: Date) +symbol swift.init OMSWalletSessionExpiredEvent.init(wallet:session:expiredAt:) init(wallet: Wallet?, session: OMSWalletSession, expiredAt: String) symbol swift.init OMSWalletUpstreamService.init(rawValue:) init?(rawValue: String) symbol swift.init Page.init(from:) init(from decoder: any Decoder) throws symbol swift.init Page.init(limit:cursor:) init(limit: UInt32? = nil, cursor: String? = nil) @@ -612,6 +613,7 @@ symbol swift.init TronTokenStandard.init(rawValue:) init?(rawValue: String) symbol swift.init TronVerificationStatus.init(rawValue:) init?(rawValue: String) symbol swift.init Wallet.init(from:) init(from decoder: any Decoder) throws symbol swift.init Wallet.init(id:type:address:reference:keyOrigin:) init(id: String, type: WalletType, address: String, reference: String? = nil, keyOrigin: WalletKeyOrigin) +symbol swift.init WalletActivationResult.init(wallet:) init(wallet: Wallet) symbol swift.init WalletCredential.init(credentialId:expiresAt:isCaller:) init(credentialId: String, expiresAt: String, isCaller: Bool) symbol swift.init WalletCredential.init(from:) init(from decoder: any Decoder) throws symbol swift.init WalletImportCipherSuite.init(rawValue:) init?(rawValue: String) @@ -619,7 +621,6 @@ symbol swift.init WalletImportRecipientKey.init(keyId:cipherSuite:publicKey:) in symbol swift.init WalletKeyOrigin.init(from:) init(from decoder: Decoder) throws symbol swift.init WalletKeyOrigin.init(wireValue:) init(wireValue: String) symbol swift.init WalletSelectionBehavior.init(rawValue:) init?(rawValue: String) -symbol swift.init WalletSelectionResult.init(wallet:) init(wallet: Wallet) symbol swift.init WalletType.init(from:) init(from decoder: Decoder) throws symbol swift.init WalletType.init(wireValue:) init(wireValue: String) symbol swift.method ContractTokenBalance.encode(to:) func encode(to encoder: Encoder) throws @@ -637,8 +638,8 @@ symbol swift.method OMSWalletEmailSessionAuth.encode(to:) func encode(to encoder symbol swift.method OMSWalletOidcSessionAuth.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method OMSWalletSessionAuth.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method OMSWalletSessionExpiredObservation.cancel() func cancel() -symbol swift.method PendingWalletSelection.createAndSelectWallet(reference:) @discardableResult func createAndSelectWallet(reference: String? = nil) async throws -> WalletSelectionResult -symbol swift.method PendingWalletSelection.selectWallet(walletId:) @discardableResult func selectWallet(walletId: String) async throws -> WalletSelectionResult +symbol swift.method PendingWalletSelection.createAndSelectWallet(reference:) @discardableResult func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult +symbol swift.method PendingWalletSelection.selectWallet(walletId:) @discardableResult func selectWallet(walletId: String) async throws -> WalletActivationResult symbol swift.method TokenMetadata.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method TokenMetadataAsset.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method Transaction.encode(to:) func encode(to encoder: Encoder) throws @@ -647,24 +648,24 @@ symbol swift.method TransactionStatus.encode(to:) func encode(to encoder: Encode symbol swift.method TransactionTransfer.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method WalletClient.addSessionExpiredObserver(_:) @discardableResult func addSessionExpiredObserver(_ observer: @escaping @MainActor @Sendable (OMSWalletSessionExpiredEvent) -> Void) -> OMSWalletSessionExpiredObservation symbol swift.method WalletClient.authorizeRemoteAccess(credentialId:network:grants:expiresAt:sessionId:) func authorizeRemoteAccess(credentialId: String, network: Network, grants: [SmartSessionGrant], expiresAt: String, sessionId: String? = nil) async throws -> AuthorizedRemoteAccess -symbol swift.method WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:) func callContract(network: Network, contract: String, method: String, args: [AbiArg]?, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse -symbol swift.method WalletClient.callTronContract(network:contract:method:args:selectFeeOption:waitForStatus:statusPolling:) func callTronContract(network: TronNetwork, contract: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +symbol swift.method WalletClient.callContract(network:contractAddress:method:args:selectFeeOption:mode:waitForStatus:statusPolling:) func callContract(network: Network, contractAddress: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse +symbol swift.method WalletClient.callTronContract(network:contractAddress:method:args:selectFeeOption:waitForStatus:statusPolling:) func callTronContract(network: TronNetwork, contractAddress: String, method: String, args: [AbiArg]? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.completeEmailAuth(code:walletSelection:walletType:) @discardableResult func completeEmailAuth(code: String, walletSelection: WalletSelectionBehavior = .automatic, walletType: WalletType = WalletType.ethereum) async throws -> CompleteAuthResult -symbol swift.method WalletClient.createWallet(walletType:reference:) @discardableResult func createWallet(walletType: WalletType = WalletType.ethereum, reference: String? = nil) async throws -> WalletSelectionResult +symbol swift.method WalletClient.createWallet(walletType:reference:) @discardableResult func createWallet(walletType: WalletType = WalletType.ethereum, reference: String? = nil) async throws -> WalletActivationResult symbol swift.method WalletClient.getIdToken(ttlSeconds:customClaims:) func getIdToken(ttlSeconds: UInt32? = nil, customClaims: [String : JSONValue]? = nil) async throws -> String symbol swift.method WalletClient.getRemoteAccessSession(sessionId:) func getRemoteAccessSession(sessionId: String) async throws -> RemoteAccessSession symbol swift.method WalletClient.getRemoteAccessSessionUsage(sessionId:network:) func getRemoteAccessSessionUsage(sessionId: String, network: Network) async throws -> [SmartSessionGrantUsage] symbol swift.method WalletClient.getTransactionStatus(txnId:) func getTransactionStatus(txnId: String) async throws -> TransactionStatusResponse symbol swift.method WalletClient.getWalletImportRecipientKey(cipherSuite:) func getWalletImportRecipientKey(cipherSuite: WalletImportCipherSuite) async throws -> WalletImportRecipientKey symbol swift.method WalletClient.handleOIDCRedirectCallback(_:walletSelection:sessionLifetimeSeconds:) func handleOIDCRedirectCallback(_ callbackURL: String?, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil) async throws -> OIDCRedirectAuthResult -symbol swift.method WalletClient.importEncryptedWallet(walletType:keyMaterial:reference:) @discardableResult func importEncryptedWallet(walletType: WalletType, keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil) async throws -> WalletSelectionResult -symbol swift.method WalletClient.importWallet(privateKey:reference:) @discardableResult func importWallet(privateKey: WalletImportPrivateKey, reference: String? = nil) async throws -> WalletSelectionResult +symbol swift.method WalletClient.importEncryptedWallet(walletType:keyMaterial:reference:) @discardableResult func importEncryptedWallet(walletType: WalletType, keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil) async throws -> WalletActivationResult +symbol swift.method WalletClient.importWallet(privateKey:reference:) @discardableResult func importWallet(privateKey: WalletImportPrivateKey, reference: String? = nil) async throws -> WalletActivationResult symbol swift.method WalletClient.inspectRemoteCredential(credentialId:) func inspectRemoteCredential(credentialId: String) async throws -> RemoteCredentialMetadata -symbol swift.method WalletClient.isValidMessageSignature(network:walletAddress:message:signature:) func isValidMessageSignature(network: Network, walletAddress: String, message: String, signature: String) async throws -> Bool -symbol swift.method WalletClient.isValidSolanaMessageSignature(walletAddress:message:signature:) func isValidSolanaMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool -symbol swift.method WalletClient.isValidTronMessageSignature(walletAddress:message:signature:) func isValidTronMessageSignature(walletAddress: String, message: String, signature: String) async throws -> Bool -symbol swift.method WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:) func isValidTronTypedDataSignature(walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool -symbol swift.method WalletClient.isValidTypedDataSignature(network:walletAddress:typedData:signature:) func isValidTypedDataSignature(network: Network, walletAddress: String, typedData: JSONValue, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidMessageSignature(network:walletAddress:message:signature:) func isValidMessageSignature(network: Network, walletAddress: String? = nil, message: String, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidSolanaMessageSignature(walletAddress:message:signature:) func isValidSolanaMessageSignature(walletAddress: String? = nil, message: String, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidTronMessageSignature(walletAddress:message:signature:) func isValidTronMessageSignature(walletAddress: String? = nil, message: String, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:) func isValidTronTypedDataSignature(walletAddress: String? = nil, typedData: JSONValue, signature: String) async throws -> Bool +symbol swift.method WalletClient.isValidTypedDataSignature(network:walletAddress:typedData:signature:) func isValidTypedDataSignature(network: Network, walletAddress: String? = nil, typedData: JSONValue, signature: String) async throws -> Bool symbol swift.method WalletClient.listAccess(pageSize:type:) func listAccess(pageSize: UInt32? = nil, type: AccessGrantType? = nil) async throws -> [AccessGrant] symbol swift.method WalletClient.listAccessPage(pageSize:cursor:type:) func listAccessPage(pageSize: UInt32? = nil, cursor: String? = nil, type: AccessGrantType? = nil) async throws -> AccessGrantPage symbol swift.method WalletClient.listAccessPages(pageSize:type:) func listAccessPages(pageSize: UInt32? = nil, type: AccessGrantType? = nil) -> ListAccessPages @@ -674,17 +675,17 @@ symbol swift.method WalletClient.sendSolanaTransfer(network:asset:to:amount:sele symbol swift.method WalletClient.sendTransaction(network:request:selectFeeOption:waitForStatus:statusPolling:) func sendTransaction(network: Network, request: SendTransactionRequest, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.sendTransaction(network:to:value:selectFeeOption:mode:waitForStatus:statusPolling:) func sendTransaction(network: Network, to: String, value: String, selectFeeOption: FeeOptionSelector? = nil, mode: TransactionMode = .relayer, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse symbol swift.method WalletClient.sendTronTransaction(network:to:value:data:selectFeeOption:waitForStatus:statusPolling:) func sendTronTransaction(network: TronNetwork, to: String, value: String = "0", data: String? = nil, selectFeeOption: FeeOptionSelector? = nil, waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions()) async throws -> SendTransactionResponse -symbol swift.method WalletClient.signInWithOidcIdToken(idToken:issuer:audience:walletType:walletSelection:sessionLifetimeSeconds:provider:providerLabel:) @discardableResult func signInWithOidcIdToken(idToken: String, issuer: String, audience: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior = .automatic, sessionLifetimeSeconds: UInt32 = 604_800, provider: String? = nil, providerLabel: String? = nil) async throws -> CompleteAuthResult +symbol swift.method WalletClient.signInWithOidcIdToken(idToken:issuer:audience:walletType:walletSelection:sessionLifetimeSeconds:provider:providerLabel:) @discardableResult func signInWithOidcIdToken(idToken: String, issuer: String, audience: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior = .automatic, sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds, provider: String? = nil, providerLabel: String? = nil) async throws -> CompleteAuthResult symbol swift.method WalletClient.signMessage(network:message:) func signMessage(network: Network, message: String) async throws -> String symbol swift.method WalletClient.signOut() func signOut() throws symbol swift.method WalletClient.signSolanaMessage(message:) func signSolanaMessage(message: String) async throws -> String symbol swift.method WalletClient.signTronMessage(message:) func signTronMessage(message: String) async throws -> String symbol swift.method WalletClient.signTronTypedData(typedData:) func signTronTypedData(typedData: JSONValue) async throws -> String symbol swift.method WalletClient.signTypedData(network:typedData:) func signTypedData(network: Network, typedData: JSONValue) async throws -> String -symbol swift.method WalletClient.startEmailAuth(email:sessionLifetimeSeconds:) func startEmailAuth(email: String, sessionLifetimeSeconds: UInt32 = 604_800) async throws -symbol swift.method WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:loginHint:walletSelection:sessionLifetimeSeconds:) func startOIDCRedirectAuth(provider: OMSRelayOIDCProvider, omsRelayReturnURI: String, walletType: WalletType = WalletType.ethereum, loginHint: String? = nil, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil) async throws -> StartOIDCRedirectAuthResult -symbol swift.method WalletClient.startOIDCRedirectAuth(provider:walletType:loginHint:authorizeParams:walletSelection:sessionLifetimeSeconds:) func startOIDCRedirectAuth(provider: CustomOIDCProviderConfiguration, walletType: WalletType = WalletType.ethereum, loginHint: String? = nil, authorizeParams: [String : String] = [:], walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil) async throws -> StartOIDCRedirectAuthResult -symbol swift.method WalletClient.useWallet(walletId:) @discardableResult func useWallet(walletId: String) async throws -> WalletSelectionResult +symbol swift.method WalletClient.startEmailAuth(email:sessionLifetimeSeconds:) func startEmailAuth(email: String, sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds) async throws +symbol swift.method WalletClient.startOIDCRedirectAuth(provider:omsRelayReturnURI:walletType:walletSelection:sessionLifetimeSeconds:loginHint:) func startOIDCRedirectAuth(provider: OMSRelayOIDCProvider, omsRelayReturnURI: String, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil, loginHint: String? = nil) async throws -> StartOIDCRedirectAuthResult +symbol swift.method WalletClient.startOIDCRedirectAuth(provider:walletType:walletSelection:sessionLifetimeSeconds:loginHint:authorizeParams:) func startOIDCRedirectAuth(provider: CustomOIDCProviderConfiguration, walletType: WalletType = WalletType.ethereum, walletSelection: WalletSelectionBehavior? = nil, sessionLifetimeSeconds: UInt32? = nil, loginHint: String? = nil, authorizeParams: [String : String] = [:]) async throws -> StartOIDCRedirectAuthResult +symbol swift.method WalletClient.useWallet(walletId:) @discardableResult func useWallet(walletId: String) async throws -> WalletActivationResult symbol swift.method WalletKeyOrigin.encode(to:) func encode(to encoder: Encoder) throws symbol swift.method WalletType.encode(to:) func encode(to encoder: Encoder) throws symbol swift.property AbiArg.type let type: String @@ -794,7 +795,6 @@ symbol swift.property NativeTokenBalance.name let name: String symbol swift.property NativeTokenBalance.priceUSD let priceUSD: String? symbol swift.property NativeTokenBalance.priceUpdatedAt let priceUpdatedAt: String? symbol swift.property NativeTokenBalance.symbol let symbol: String -symbol swift.property Network.chainId var chainId: String { get } symbol swift.property Network.description var description: String { get } symbol swift.property Network.displayName var displayName: String { get } symbol swift.property Network.explorerURL var explorerURL: URL { get } @@ -819,9 +819,9 @@ symbol swift.property OMSWalletOidcSessionAuth.issuer let issuer: String symbol swift.property OMSWalletOidcSessionAuth.provider let provider: String? symbol swift.property OMSWalletOidcSessionAuth.providerLabel let providerLabel: String? symbol swift.property OMSWalletSession.auth let auth: OMSWalletSessionAuth -symbol swift.property OMSWalletSession.expiresAt let expiresAt: Date +symbol swift.property OMSWalletSession.expiresAt let expiresAt: String symbol swift.property OMSWalletSessionAuth.email var email: String? { get } -symbol swift.property OMSWalletSessionExpiredEvent.expiredAt let expiredAt: Date +symbol swift.property OMSWalletSessionExpiredEvent.expiredAt let expiredAt: String symbol swift.property OMSWalletSessionExpiredEvent.session let session: OMSWalletSession symbol swift.property OMSWalletSessionExpiredEvent.wallet let wallet: Wallet? symbol swift.property OMSWalletUpstreamError.code let code: String? @@ -858,6 +858,19 @@ symbol swift.property SendTransactionResponse.txnHash let txnHash: String? symbol swift.property SendTransactionResponse.txnId let txnId: String symbol swift.property SmartSessionGrantUsage.grant let grant: SmartSessionGrant symbol swift.property SmartSessionGrantUsage.used let used: String? +symbol swift.property SolanaBalance.accountAddress var accountAddress: String { get } +symbol swift.property SolanaBalance.balance var balance: String { get } +symbol swift.property SolanaBalance.balanceUSD var balanceUSD: String? { get } +symbol swift.property SolanaBalance.decimals var decimals: Int { get } +symbol swift.property SolanaBalance.formattedBalance var formattedBalance: String { get } +symbol swift.property SolanaBalance.imageUrl var imageUrl: String? { get } +symbol swift.property SolanaBalance.metadataUri var metadataUri: String? { get } +symbol swift.property SolanaBalance.name var name: String { get } +symbol swift.property SolanaBalance.network var network: SolanaNetwork { get } +symbol swift.property SolanaBalance.priceUSD var priceUSD: String? { get } +symbol swift.property SolanaBalance.symbol var symbol: String { get } +symbol swift.property SolanaBalance.verificationSource var verificationSource: SolanaVerificationSource { get } +symbol swift.property SolanaBalance.verificationStatus var verificationStatus: SolanaVerificationStatus { get } symbol swift.property SolanaBalancesResult.balances let balances: [SolanaBalance] symbol swift.property SolanaBalancesResult.errors let errors: [SolanaNetworkError] symbol swift.property SolanaBalancesResult.status let status: Int @@ -991,6 +1004,19 @@ symbol swift.property TransactionTransfer.to let to: String symbol swift.property TransactionTransfer.tokenIds let tokenIds: [String]? symbol swift.property TransactionTransfer.tokenMetadata let tokenMetadata: [String : TokenMetadata]? symbol swift.property TransactionTransfer.transferType let transferType: String +symbol swift.property TronBalance.accountAddress var accountAddress: String { get } +symbol swift.property TronBalance.balance var balance: String { get } +symbol swift.property TronBalance.balanceUSD var balanceUSD: String? { get } +symbol swift.property TronBalance.decimals var decimals: Int { get } +symbol swift.property TronBalance.formattedBalance var formattedBalance: String { get } +symbol swift.property TronBalance.imageUrl var imageUrl: String? { get } +symbol swift.property TronBalance.metadataUri var metadataUri: String? { get } +symbol swift.property TronBalance.name var name: String { get } +symbol swift.property TronBalance.network var network: TronNetwork { get } +symbol swift.property TronBalance.priceUSD var priceUSD: String? { get } +symbol swift.property TronBalance.symbol var symbol: String { get } +symbol swift.property TronBalance.verificationSource var verificationSource: String { get } +symbol swift.property TronBalance.verificationStatus var verificationStatus: TronVerificationStatus { get } symbol swift.property TronBalancesResult.balances let balances: [TronBalance] symbol swift.property TronBalancesResult.errors let errors: [TronNetworkError] symbol swift.property TronBalancesResult.status let status: Int @@ -1030,17 +1056,17 @@ symbol swift.property Wallet.id let id: String symbol swift.property Wallet.keyOrigin let keyOrigin: WalletKeyOrigin symbol swift.property Wallet.reference let reference: String? symbol swift.property Wallet.type let type: WalletType +symbol swift.property WalletActivationResult.wallet let wallet: Wallet symbol swift.property WalletClient.activeWallet var activeWallet: Wallet? { get set } symbol swift.property WalletClient.session var session: OMSWalletSession? { get } -symbol swift.property WalletClient.walletId var walletId: String { get set } symbol swift.property WalletCredential.credentialId let credentialId: String symbol swift.property WalletCredential.expiresAt let expiresAt: String symbol swift.property WalletCredential.isCaller let isCaller: Bool +symbol swift.property WalletImportPrivateKey.walletType var walletType: WalletType { get } symbol swift.property WalletImportRecipientKey.cipherSuite let cipherSuite: WalletImportCipherSuite symbol swift.property WalletImportRecipientKey.keyId let keyId: String symbol swift.property WalletImportRecipientKey.publicKey let publicKey: String symbol swift.property WalletKeyOrigin.wireValue var wireValue: String { get } -symbol swift.property WalletSelectionResult.wallet let wallet: Wallet symbol swift.property WalletType.wireValue var wireValue: String { get } symbol swift.struct AbiArg struct AbiArg symbol swift.struct AccessGrantPage struct AccessGrantPage @@ -1097,14 +1123,13 @@ symbol swift.struct TronFungibleTokenBalance struct TronFungibleTokenBalance symbol swift.struct TronNativeBalance struct TronNativeBalance symbol swift.struct TronNetworkError struct TronNetworkError symbol swift.struct Wallet struct Wallet +symbol swift.struct WalletActivationResult struct WalletActivationResult symbol swift.struct WalletCredential struct WalletCredential symbol swift.struct WalletImportRecipientKey struct WalletImportRecipientKey -symbol swift.struct WalletSelectionResult struct WalletSelectionResult symbol swift.type.method FeeOptionSelector.custom(_:) static func custom(_ pick: @escaping Select) -> FeeOptionSelector symbol swift.type.method Network.findById(_:) static func findById(_ chainId: Int) -> Network? symbol swift.type.method Network.findByName(_:) static func findByName(_ name: String) -> Network? symbol swift.type.property FeeOptionSelector.firstAvailable static let firstAvailable: FeeOptionSelector -symbol swift.type.property Network.amoy static let amoy: Network symbol swift.type.property Network.supportedNetworks static var supportedNetworks: [Network] { get } symbol swift.type.property OMSRelayOIDCProviders.apple static let apple: OMSRelayOIDCProvider symbol swift.type.property OMSRelayOIDCProviders.google static let google: OMSRelayOIDCProvider @@ -1112,5 +1137,7 @@ symbol swift.type.property SolanaNetworks.devnet static let devnet: SolanaNetwor symbol swift.type.property SolanaNetworks.mainnet static let mainnet: SolanaNetwork symbol swift.type.property TronNetworks.mainnet static let mainnet: TronNetwork symbol swift.type.property TronNetworks.nile static let nile: TronNetwork +symbol swift.type.property WalletClient.defaultSessionLifetimeSeconds static let defaultSessionLifetimeSeconds: UInt32 +symbol swift.type.property WalletClient.maxSessionLifetimeSeconds static let maxSessionLifetimeSeconds: UInt32 symbol swift.typealias FeeOptionSelector.Select typealias Select = @Sendable (_ options: [FeeOptionWithBalance]) async throws -> FeeOptionSelection? symbol swift.typealias ListAccessPages.Element typealias Element = AccessGrantPage From 37d90bf3bf9746936124fbcffe17c30b823e4c3e Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 16:27:17 +0300 Subject: [PATCH 04/35] feat(demo): add Solana to the SDK demo app --- .../oms-wallet-demo/ContentView.swift | 468 +++++++++++++++++- 1 file changed, 462 insertions(+), 6 deletions(-) diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index 7d1f8d3..f9a99bd 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -970,7 +970,11 @@ struct WalletSelectionWindow: View { } private var walletLeadingText: String { - pendingSelection.walletType == .tron ? "T" : "0x" + switch pendingSelection.walletType { + case .tron: "T" + case .solana: "SOL" + default: "0x" + } } private func walletSelectionSubtitle(_ wallet: Wallet) -> String { @@ -998,6 +1002,7 @@ struct WalletSelectionWindow: View { enum DemoWalletType: String, CaseIterable, Identifiable { case ethereum = "Ethereum" + case solana = "Solana" case tron = "Tron" var id: String { rawValue } @@ -1005,6 +1010,7 @@ enum DemoWalletType: String, CaseIterable, Identifiable { var walletType: WalletType { switch self { case .ethereum: .ethereum + case .solana: .solana case .tron: .tron } } @@ -1150,9 +1156,12 @@ struct WalletWindow: View { } var body: some View { - if vm.omsWallet.wallet.activeWallet?.type == .tron { + switch vm.omsWallet.wallet.activeWallet?.type { + case .tron: TronWalletWindow() - } else { + case .solana: + SolanaWalletWindow() + default: evmWalletTabs } } @@ -1443,7 +1452,7 @@ private func tronAddressExplorerURL(_ address: String) -> URL? { URL(string: "\(tronscanNileURL)/address/\(address)") } -private func formatTronAmount(_ raw: String, decimals: Int) -> String { +private func formatTokenAmount(_ raw: String, decimals: Int) -> String { trimBalanceDisplay((try? formatUnits(value: raw, decimals: decimals)) ?? raw, maxFractionDigits: decimals) } @@ -1585,8 +1594,8 @@ private struct TronBalancesWindow: View { break } } - trxBalance = formatTronAmount(trxRaw, decimals: trxDecimals) - usdtBalance = formatTronAmount(usdtRaw, decimals: nileUsdtDecimals) + trxBalance = formatTokenAmount(trxRaw, decimals: trxDecimals) + usdtBalance = formatTokenAmount(usdtRaw, decimals: nileUsdtDecimals) } catch { trxBalance = "—" usdtBalance = "—" @@ -1846,6 +1855,453 @@ private struct TronSignMessageWindow: View { } } +// MARK: - Solana Wallet Window + +private let solanaExplorerURL = "https://explorer.solana.com" +private let solFaucetURL = URL(string: "https://faucet.solana.com/")! +private let devnetUsdcFaucetURL = URL(string: "https://faucet.circle.com/")! +private let devnetUsdcMint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" +private let devnetUsdcDecimals = 6 +private let solDecimals = 9 + +private func solanaTransactionExplorerURL(_ signature: String) -> URL? { + URL(string: "\(solanaExplorerURL)/tx/\(signature)?cluster=devnet") +} + +private func solanaAddressExplorerURL(_ address: String) -> URL? { + URL(string: "\(solanaExplorerURL)/address/\(address)?cluster=devnet") +} + +private struct SolanaDemoError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +/// Converts a decimal amount to base units, rejecting malformed and zero amounts. +private func solanaBaseUnits(_ amount: String, decimals: Int, label: String) throws -> String { + let raw: String + do { + raw = try parseUnits(value: amount, decimals: decimals) + } catch { + throw SolanaDemoError(message: "Enter a valid \(label) amount.") + } + guard !raw.hasPrefix("-"), raw != "0" else { + throw SolanaDemoError(message: "Enter a \(label) amount greater than zero.") + } + return raw +} + +struct SolanaWalletWindow: View { + @EnvironmentObject private var vm: AppViewModel + + var body: some View { + TabView { + SolanaBalancesWindow() + .tabItem { + Label("Wallet", systemImage: "wallet.pass") + } + + SolanaSendWindow() + .tabItem { + Label("Send", systemImage: "arrow.up.circle") + } + + SolanaSignMessageWindow() + .tabItem { + Label("Sign", systemImage: "signature") + } + } + .tint(DesignTokens.Color.info) + #if os(macOS) + .frame(minWidth: 640, minHeight: 560) + #endif + } +} + +private struct SolanaBalancesWindow: View { + @EnvironmentObject private var vm: AppViewModel + + @State private var solBalance: String = "—" + @State private var usdcBalance: String = "—" + @State private var balanceStatus: String? + @State private var isFetchingBalance: Bool = false + @State private var error: GenericAppError? + + var body: some View { + ModalContainer( + title: "Solana wallet", + subtitle: "Devnet. Transfers are relayed, so the network fee is sponsored. Sending an SPL token to a wallet without a token account asks you to choose how to pay its rent.", + showsCloseButton: false + ) { + if let address = vm.omsWallet.wallet.activeWallet?.address { + FieldGroup(title: "Address") { + CopyableResult(text: address) + if let url = solanaAddressExplorerURL(address) { + Link("View wallet on Solana Explorer", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + + DesignText(sessionAuthLabel(vm.omsWallet.wallet.session?.auth), variant: .caption) + + Panel { + solanaBalanceRow( + title: "Devnet SOL balance", + value: "\(solBalance) SOL", + faucetTitle: "Open SOL faucet", + faucetURL: solFaucetURL + ) + solanaBalanceRow( + title: "Devnet USDC balance", + value: "\(usdcBalance) USDC", + faucetTitle: "Open USDC faucet", + faucetURL: devnetUsdcFaucetURL + ) + + if let balanceStatus { + DesignText(balanceStatus, variant: .error) + } + } + + Button { + Task { await refreshBalances() } + } label: { + label(for: "Refresh balances", systemImage: "arrow.clockwise", loading: isFetchingBalance) + } + .buttonStyle(DesignButtonStyle(variant: .secondary)) + .disabled(isFetchingBalance) + + Button { + vm.signOut() + } label: { + label(for: "Sign out", systemImage: "rectangle.portrait.and.arrow.right", loading: false) + } + .buttonStyle(DesignButtonStyle(variant: .secondary)) + } + .genericErrorWindow(error: $error) + .task { + await refreshBalances() + } + } + + private func solanaBalanceRow(title: String, value: String, faucetTitle: String, faucetURL: URL) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + DesignText(title, variant: .caption) + Link(faucetTitle, destination: faucetURL) + .font(.caption.weight(.semibold)) + } + + Spacer() + + if isFetchingBalance { + ProgressView() + .controlSize(.small) + .tint(DesignTokens.Color.info) + } else { + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(DesignTokens.Color.primaryText) + .monospacedDigit() + } + } + } + + private func refreshBalances() async { + guard let address = vm.omsWallet.wallet.activeWallet?.address else { return } + isFetchingBalance = true + balanceStatus = nil + defer { isFetchingBalance = false } + + do { + let result = try await vm.omsWallet.indexer.getSolanaBalances( + GetSolanaBalancesParams( + walletAddress: address, + networks: [.devnet], + mintAddresses: [devnetUsdcMint] + ) + ) + if let networkError = result.errors.first(where: { $0.network == .devnet }) { + solBalance = "—" + usdcBalance = "—" + balanceStatus = networkError.reason + return + } + + var solRaw = "0" + var usdcRaw = "0" + for balance in result.balances where balance.network == .devnet { + switch balance { + case .native: + solRaw = balance.balance + case .fungibleToken(let token) where token.mintAddress == devnetUsdcMint: + usdcRaw = balance.balance + case .fungibleToken: + break + } + } + solBalance = formatTokenAmount(solRaw, decimals: solDecimals) + usdcBalance = formatTokenAmount(usdcRaw, decimals: devnetUsdcDecimals) + } catch { + solBalance = "—" + usdcBalance = "—" + self.error = GenericAppError(error) + } + } +} + +private enum SolanaTransferAsset: String, CaseIterable, Identifiable { + case sol = "SOL" + case usdc = "USDC" + case custom = "Custom SPL" + + var id: String { rawValue } +} + +private struct SolanaSendWindow: View { + @EnvironmentObject private var vm: AppViewModel + + @State private var asset: SolanaTransferAsset = .sol + @State private var recipientText: String = "" + @State private var amountText: String = "0.001" + @State private var mintText: String = "" + @State private var tokenDecimalsText: String = "6" + @State private var result: SendTransactionResponse? + @State private var transferStatus: String? + @State private var isSending: Bool = false + @State private var error: GenericAppError? + + var body: some View { + ModalContainer( + title: "Send on Solana Devnet", + subtitle: "Send SOL or an SPL token with sendSolanaTransfer. Transfers are relayed; the wallet service creates the recipient's token account when needed.", + showsCloseButton: false + ) { + FieldGroup(title: "Asset") { + Picker("Asset", selection: $asset) { + ForEach(SolanaTransferAsset.allCases) { asset in + Text(asset.rawValue).tag(asset) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(isSending) + + if asset == .usdc { + DesignText("USDC mint address: \(devnetUsdcMint)", variant: .caption) + .textSelection(.enabled) + } + } + + if asset == .custom { + FieldGroup(title: "Token mint") { + TextField("SPL or Token-2022 mint address", text: $mintText) + .tokenTextInput() + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + + FieldGroup(title: "Token decimals") { + TextField("6", text: $tokenDecimalsText) + .tokenTextInput() + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + } + + FieldGroup(title: "Recipient wallet") { + TextField("Solana wallet address", text: $recipientText) + .tokenTextInput() + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + + FieldGroup(title: amountTitle) { + TextField("Enter amount", text: $amountText) + .tokenTextInput() + #if os(iOS) + .keyboardType(.decimalPad) + #endif + } + + if asset != .sol { + DesignText("Enter the recipient's wallet address, not a token account. If the recipient has no token account yet, you choose how to pay its rent before the transfer is sent.", variant: .caption) + } + + Button { + Task { await sendTransfer() } + } label: { + label(for: "Send on Solana Devnet", systemImage: "paperplane", loading: isSending) + } + .buttonStyle(DesignButtonStyle(variant: .primary)) + .disabled(!canSend || isSending) + + if let transferStatus { + DesignText(transferStatus, variant: .body) + } + + if let result { + TransactionResultPanel(result: result) + + if let signature = result.txnHash, !signature.isEmpty, + let url = solanaTransactionExplorerURL(signature) { + Link("View on Solana Explorer", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + .genericErrorWindow(error: $error) + .sheet(item: $vm.feeOptionSelectionRequest) { request in + FeeOptionSelectionWindow(request: request) + .environmentObject(vm) + } + .onChange(of: asset) { asset in + amountText = asset == .sol ? "0.001" : "1" + } + } + + private var amountTitle: String { + switch asset { + case .sol: "Amount (SOL)" + case .usdc: "Amount (USDC)" + case .custom: "Amount (token units)" + } + } + + private var canSend: Bool { + let hasRequiredFields = !recipientText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !amountText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard asset == .custom else { return hasRequiredFields } + return hasRequiredFields + && !mintText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && tokenDecimals != nil + } + + private var tokenDecimals: Int? { + guard let decimals = Int(tokenDecimalsText.trimmingCharacters(in: .whitespacesAndNewlines)), + (0...255).contains(decimals) else { + return nil + } + return decimals + } + + private func sendTransfer() async { + let recipient = recipientText.trimmingCharacters(in: .whitespacesAndNewlines) + let amount = amountText.trimmingCharacters(in: .whitespacesAndNewlines) + isSending = true + result = nil + transferStatus = "Preparing relayed transfer..." + defer { isSending = false } + + let selectFeeOption = FeeOptionSelector.custom { options in + try await vm.selectFeeOption(options) + } + + do { + let transferAsset: String + let baseUnits: String + switch asset { + case .sol: + transferAsset = "SOL" + baseUnits = try solanaBaseUnits(amount, decimals: solDecimals, label: "SOL") + case .usdc: + transferAsset = devnetUsdcMint + baseUnits = try solanaBaseUnits(amount, decimals: devnetUsdcDecimals, label: "USDC") + case .custom: + transferAsset = mintText.trimmingCharacters(in: .whitespacesAndNewlines) + baseUnits = try solanaBaseUnits(amount, decimals: tokenDecimals ?? 0, label: "token") + } + + let transaction = try await vm.omsWallet.wallet.sendSolanaTransfer( + network: .devnet, + asset: transferAsset, + to: recipient, + amount: baseUnits, + selectFeeOption: selectFeeOption, + statusPolling: TransactionStatusPollingOptions(timeoutMs: 120_000) + ) + + result = transaction + transferStatus = transaction.status == .executed || transaction.status == .failed + ? "Transfer \(transaction.status.wireValue)." + : "Transaction submitted." + } catch { + transferStatus = nil + self.error = GenericAppError(error) + } + } +} + +private struct SolanaSignMessageWindow: View { + @EnvironmentObject private var vm: AppViewModel + + @State private var messageText: String = "Sign in to OMS Wallet" + @State private var signature: String = "" + @State private var signStatus: String? + @State private var isSigning: Bool = false + @State private var error: GenericAppError? + + var body: some View { + ModalContainer( + title: "Sign Solana message", + subtitle: "Sign an off-chain message with the Solana wallet and verify the signature against the active wallet.", + showsCloseButton: false + ) { + FieldGroup(title: "Message") { + TextField("Enter message", text: $messageText) + .tokenTextInput() + } + + Button { + Task { await signAndVerify() } + } label: { + label(for: "Sign Solana message", systemImage: "signature", loading: isSigning) + } + .buttonStyle(DesignButtonStyle(variant: .primary)) + .disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSigning) + + if !signature.isEmpty { + ResultPanel(title: "Signature", text: signature) + } + + if let signStatus { + DesignText(signStatus, variant: .body) + } + } + .genericErrorWindow(error: $error) + } + + private func signAndVerify() async { + let message = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + isSigning = true + signature = "" + signStatus = "Signing message..." + defer { isSigning = false } + + do { + let signed = try await vm.omsWallet.wallet.signSolanaMessage(message: message) + signature = signed + signStatus = "Verifying signature..." + // Without `walletAddress`, the SDK verifies against the active Solana wallet. + let isValid = try await vm.omsWallet.wallet.isValidSolanaMessageSignature( + message: message, + signature: signed + ) + signStatus = isValid ? "Message signed and verified." : "Signature verification failed." + } catch { + signStatus = nil + self.error = GenericAppError(error) + } + } +} + // MARK: - Sign Message Window struct SignMessageWindow: View { From 5df2d639d85bfdf3fbbb8636069967b104076163 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 16:29:36 +0300 Subject: [PATCH 05/35] docs(agents): describe optional walletAddress in verification APIs --- AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8db450d..83e7fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,9 +106,10 @@ execution commands. persist private key material in SDK session storage. - Keep OIDC redirect state separate from completed wallet session state. Invalid or unrelated callbacks should not clear pending redirect auth. -- Verification APIs require an explicit `walletAddress`. Public wallet methods - should validate active `walletId` and credential state before building signed - requests. +- Verification APIs take an optional `walletAddress` and fall back to the active + wallet's address; they never send a wallet ID and do not require a session when + an address is given. Other public wallet methods should validate the active + wallet and credential state before building signed requests. - Avoid floating-point math for token amounts. Use or extend `parseUnits` and `formatUnits` for base-unit conversions. - Prefer `rg --files`, `rg`, `swift build`, and `swift test` from the repository root. From 7cd99931fa04b68f03bd80d75555c513eb005b7e Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:54:01 +0300 Subject: [PATCH 06/35] fix: reject unrestorable wallets, clean up bad auth expiry, and blank verification addresses - Sign-in no longer activates a wallet with an unknown network family or key origin that session restore would discard; it throws invalidResponse. - completeEmailAuth signs out when the auth response has an unreadable credential expiresAt, like the other post-auth failures. - isValid*Signature methods reject an empty or whitespace-only walletAddress with validationError before any request. --- MIGRATION.md | 4 ++- Sources/OMSWallet/Clients/WalletAuth.swift | 4 +++ Sources/OMSWallet/Clients/WalletClient.swift | 6 ++++ .../OMSWallet/Clients/WalletOperations.swift | 5 ++- .../Models/Sessions/StorableCredentials.swift | 3 +- .../ActiveWalletSessionTests.swift | 34 +++++++++++++++++++ .../SignatureVerificationTests.swift | 20 +++++++++++ docs/error-contracts.md | 3 +- 8 files changed, 75 insertions(+), 4 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 014f9b3..d63aeaf 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -85,7 +85,9 @@ method: "transfer" ### Wallet responses Ethereum wallets whose address is not `0x` followed by 40 hexadecimal digits are now rejected with -`.invalidResponse`. Checksum casing is not enforced. +`.invalidResponse`. Checksum casing is not enforced. Sign-in and wallet selection also reject, with +`.invalidResponse`, a wallet whose network family or key origin the SDK does not recognize, instead +of activating a wallet whose saved session would be discarded on the next launch. ### Exhaustive switches diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index 641d41e..4e48f43 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -565,6 +565,10 @@ extension WalletClient { oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil ) async throws -> CompleteAuthResult { guard OMSWalletSession.parseDate(response.credential.expiresAt) != nil else { + // The redirect callback's caller clears its own session on failure. + if oidcRedirectAuthOwnership == nil { + try? signOut() + } throw OMSWalletError(code: .invalidResponse, message: "Auth response has an invalid credential expiresAt") } let sessionMetadata = SessionMetadata( diff --git a/Sources/OMSWallet/Clients/WalletClient.swift b/Sources/OMSWallet/Clients/WalletClient.swift index 42df6eb..3928a74 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -438,6 +438,12 @@ public final class WalletClient: @unchecked Sendable { requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil ) throws { + guard StorableCredentials.isValidStoredWallet(wallet) else { + throw OMSWalletError( + code: .invalidResponse, + message: "Wallet response has an unsupported wallet type or key origin" + ) + } let persist = { try self.withSessionLock { try self.requireCurrentSessionRevisionLocked(requiredSessionRevision) diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 2767faa..93364d0 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -821,10 +821,13 @@ extension WalletClient { try requireActiveWalletType(.tron) } - /// Returns `walletAddress` when given; otherwise the active wallet's address, which must be of + /// Returns `walletAddress` when given (it must not be blank); otherwise the active wallet's address, which must be of /// `walletType`. Verification never sends a wallet ID. private func verificationWalletAddress(_ walletAddress: String?, walletType: WalletType) throws -> String { if let walletAddress { + guard !walletAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw OMSWalletError(code: .validationError, message: "walletAddress must not be empty") + } return walletAddress } try requireActiveWalletType(walletType) diff --git a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift index 4b0ab31..1324692 100644 --- a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift +++ b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift @@ -53,7 +53,8 @@ struct StorableCredentials: Codable { self.auth = try container.decode(OMSWalletSessionAuth.self, forKey: .auth) } - private static func isValidStoredWallet(_ wallet: Wallet) -> Bool { + /// Sign-in uses the same rule, so it never activates a wallet that restore would discard. + static func isValidStoredWallet(_ wallet: Wallet) -> Bool { guard !wallet.id.isEmpty, !wallet.address.isEmpty else { return false } diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift index 42ec0ef..5cdd06d 100644 --- a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -182,6 +182,40 @@ func TestDiscardsStoredSessionsWithInvalidWallets(walletJSON: String) throws { } #expect(fixture.client.activeWallet == nil) #expect(fixture.client.session == nil) + #expect(!fixture.signer.hasStoredCredential) +} + +@Test(arguments: [ + WaasWallet( + id: "wallet-1", + networkFamily: .evm, + keyOrigin: .unknown("derived"), + address: "0x1111111111111111111111111111111111111111" + ), + WaasWallet( + id: "wallet-1", + networkFamily: .unknown("bitcoin"), + keyOrigin: .enclave, + address: "bc1q" + ), +]) +func TestSignInRejectsWalletsThatCannotBeRestored(wallet: WaasWallet) async throws { + let fixture = makeMockWalletClient() + let candidates = wallet.networkFamily == .evm ? [wallet] : [] + try fixture.transport.enqueue(completeAuthResponse(wallets: candidates), for: WaasAPI.CompleteAuth.urlPath) + try fixture.transport.enqueue(UseWalletResponse(wallet: wallet), for: WaasAPI.UseWallet.urlPath) + try fixture.transport.enqueue(CreateWalletResponse(wallet: wallet), for: WaasAPI.CreateWallet.urlPath) + + do { + _ = try await fixture.client.completeEmailAuth(code: "123456") + Issue.record("Expected an invalid wallet response") + } catch let error as OMSWalletError { + #expect(error.code == .invalidResponse) + #expect(error.operation == .walletCompleteEmailAuth) + } + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) + #expect(try fixture.storedCredentials() == nil) } @Test @MainActor func TestPendingWalletSelectionExpiryNotifiesWithoutWallet() async throws { diff --git a/Tests/OMSWalletTests/SignatureVerificationTests.swift b/Tests/OMSWalletTests/SignatureVerificationTests.swift index 71ec39c..4b7b669 100644 --- a/Tests/OMSWalletTests/SignatureVerificationTests.swift +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -195,3 +195,23 @@ func TestSignatureVerificationWithoutAddressRejectsMismatchedActiveWallet( } #expect(fixture.transport.requestCount(for: method.path) == 0) } + +@Test(arguments: SignatureVerificationMethod.allCases, ["", " "]) +func TestSignatureVerificationRejectsBlankAddress( + _ method: SignatureVerificationMethod, + walletAddress: String +) async throws { + let fixture = makeMockWalletClient() + activate(fixture, type: method.walletType, address: method.address) + try method.enqueueValidResponse(fixture.transport) + + do { + _ = try await method.call(fixture.client, walletAddress: walletAddress) + Issue.record("Expected a validation error") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + #expect(error.operation == method.operation) + #expect(error.localizedDescription == "walletAddress must not be empty") + } + #expect(fixture.transport.requestCount(for: method.path) == 0) +} diff --git a/docs/error-contracts.md b/docs/error-contracts.md index feb835f..b1ee674 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -67,8 +67,9 @@ should be present, and which tests own the contract. | `omsWallet.wallet.startOIDCRedirectAuth`, `handleOIDCRedirectCallback` | Local OIDC redirect-state persistence or restore failure | Thrown `OMSWalletError` with `.storageError` | Retry OIDC auth after resolving the local storage issue | Absent | `PublicErrorContractsTests.swift` | | Protected wallet methods: `getIdToken`, signing and transaction methods, wallet import, `getTransactionStatus`, access authorization/usage/listing/revocation | Missing or expired local session | `.sessionMissing` or `.sessionExpired` | Authenticate again or recover local session; no remote request was made | Absent | `PublicErrorContractsTests.swift` | | `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called without `walletAddress` | No active wallet, or the active wallet belongs to another chain family | `.sessionMissing` (or `.sessionExpired`), or `.validationError` | Sign in with a wallet of the method's family, or pass `walletAddress`; with an address no session is required. No remote request was made | Absent | `SignatureVerificationTests.swift` | +| `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called with an empty or whitespace-only `walletAddress` | Blank address; it does not fall back to the active wallet | `.validationError` ("walletAddress must not be empty") | Pass a wallet address, or omit `walletAddress` to use the active wallet. No remote request was made | Absent | `SignatureVerificationTests.swift` | | Wallet signing, transactions, import, and owner access methods | SDK-local validation, wallet-family mismatch (checked against the active wallet's stored type), non-bare contract `method` name, or fee-selection failure | `.validationError` | Correct parameters, select the required wallet family, or fix local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift`, `WalletImportTests.swift`, `MockWalletTests.swift`, `TronWalletTests.swift` | -| Wallet auth, selection, creation, and import methods | Successful WaaS response with an Ethereum wallet address that is not `0x` plus 40 hex digits, or an unreadable credential `expiresAt` | `.invalidResponse` | Treat the payload as unusable; the wallet is not activated | Absent | `ActiveWalletSessionTests.swift` | +| Wallet auth, selection, creation, and import methods | Successful WaaS response with an Ethereum wallet address that is not `0x` plus 40 hex digits, an unreadable credential `expiresAt`, or a wallet to activate with an unknown network family or key origin | `.invalidResponse` | Treat the payload as unusable; the wallet is not activated | Absent | `ActiveWalletSessionTests.swift` | | `omsWallet.wallet.importWallet`, `importEncryptedWallet` | Address already imported (WaaS `AddressAlreadyImported`, code `7313`) | `.walletAddressAlreadyImported` (`OMS_WALLET_ADDRESS_ALREADY_IMPORTED`), `status == 409`, `retryable == false`; the active wallet is unchanged | Select the existing wallet or use a different key | Present | `PublicErrorContractsTests.swift` | | `omsWallet.wallet.getWalletImportRecipientKey`, `importWallet`, `importEncryptedWallet` | Recipient-key attestation is missing, stale, malformed, untrusted, or does not match the request/response | `.attestationVerificationFailed` with the wallet-import operation | Do not encrypt or submit key material; retry only after confirming the SDK's managed PCR0 trust policy and WaaS environment | Absent | `WalletImportTests.swift` | | `omsWallet.wallet.isValidMessageSignature`, `isValidSolanaMessageSignature`, `isValidTypedDataSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` | WaaS validation backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with the validation operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift` | From ffdb1252a243af4117008a6d8d9ec193b0e46cb9 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:54:02 +0300 Subject: [PATCH 07/35] fix(docs): name the removed CompleteAuthResult.walletAddress in MIGRATION --- MIGRATION.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index d63aeaf..e02ebed 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -35,6 +35,17 @@ case .walletSelected(let wallet, let wallets, let credential): let walletAddress = wallet.address ``` +The computed `CompleteAuthResult.walletAddress` property was removed too; use +`result.wallet?.address`: + +```swift +// 0.3.x +let walletAddress = result.walletAddress + +// 0.4.0 +let walletAddress = result.wallet?.address +``` + ### `session` is `nil` when signed out `OMSWalletSessionState` was renamed to `OMSWalletSession`, and `omsWallet.wallet.session` is now From 2fefb8a40b40fed7acff4ffc0beb6a183e5b03e0 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:54:31 +0300 Subject: [PATCH 08/35] fix(demo): keep the wallet family on reauth and clear stale Tron balances The sdk-demo now picks the wallet type from the restored session and from OMSWalletSessionExpiredEvent.wallet, so signing in again after expiry returns to the same Tron, Solana, or Ethereum wallet. A Nile balance error now resets the TRX and USDT values like the Solana panel does. --- .../oms-wallet-demo/ContentView.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index f9a99bd..5d4030d 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -288,8 +288,11 @@ final class AppViewModel: ObservableObject { } func checkSession() async { - let hasSession = omsWallet.wallet.activeWallet != nil - screen = hasSession ? .wallet : .introduction + let activeWallet = omsWallet.wallet.activeWallet + if let walletType = DemoWalletType(activeWallet?.type) { + selectedWalletType = walletType + } + screen = activeWallet != nil ? .wallet : .introduction } func signOut() { @@ -578,6 +581,10 @@ final class AppViewModel: ObservableObject { if let email = event.session.auth.email { loginEmail = email } + // Sign the user back in to the same wallet family. + if let walletType = DemoWalletType(event.wallet?.type) { + selectedWalletType = walletType + } screen = .login sessionExpiredPrompt = SessionExpiredPrompt(event: event) } @@ -1007,6 +1014,15 @@ enum DemoWalletType: String, CaseIterable, Identifiable { var id: String { rawValue } + init?(_ walletType: WalletType?) { + switch walletType { + case .ethereum: self = .ethereum + case .solana: self = .solana + case .tron: self = .tron + default: return nil + } + } + var walletType: WalletType { switch self { case .ethereum: .ethereum @@ -1578,6 +1594,8 @@ private struct TronBalancesWindow: View { ) ) if let networkError = result.errors.first(where: { $0.network == .nile }) { + trxBalance = "—" + usdtBalance = "—" balanceStatus = networkError.reason return } From dee50fb8c539f29b1e709f0147edb5ce1a6ef93a Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:57:39 +0300 Subject: [PATCH 09/35] refactor: share the empty-string balance decoder with Solana Move decodeNonEmptyString to a KeyedDecodingContainer extension and use it for the Solana balance structs too, so an empty imageUrl or metadataUri decodes as nil for both families, as in the TypeScript and Kotlin SDKs. --- MIGRATION.md | 4 ++ .../Models/Indexer/SolanaIndexerModels.swift | 41 +++++++++++++++++++ .../Models/Indexer/TronIndexerModels.swift | 9 ---- .../OMSWallet/Utils/DecodableExtension.swift | 10 +++++ Tests/OMSWalletTests/IndexerTests.swift | 2 +- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e02ebed..e6d5c1c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -273,6 +273,10 @@ if error.code == .walletAddressAlreadyImported { /* already imported */ } `WalletClient.maxSessionLifetimeSeconds` (2,592,000) are now public. Remove app extensions that declare members with these names on those types. +### Empty Solana balance metadata + +`SolanaBalance` now decodes an empty `imageUrl` or `metadataUri` as `nil`, as `TronBalance` does. + ## 0.3.0 ### Wallet types and key origin diff --git a/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift index cefdeb8..67e74af 100644 --- a/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift +++ b/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift @@ -51,6 +51,47 @@ public struct SolanaFungibleTokenBalance: Codable, Sendable { public let balanceUSD: String? } +// Empty `imageUrl` and `metadataUri` decode as nil, matching the Tron balances. +extension SolanaNativeBalance { + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + network = try container.decode(SolanaNetwork.self, forKey: .network) + accountAddress = try container.decode(String.self, forKey: .accountAddress) + name = try container.decode(String.self, forKey: .name) + symbol = try container.decode(String.self, forKey: .symbol) + decimals = try container.decode(Int.self, forKey: .decimals) + balance = try container.decode(String.self, forKey: .balance) + formattedBalance = try container.decode(String.self, forKey: .formattedBalance) + imageUrl = try container.decodeNonEmptyString(forKey: .imageUrl) + metadataUri = try container.decodeNonEmptyString(forKey: .metadataUri) + verificationStatus = try container.decode(SolanaVerificationStatus.self, forKey: .verificationStatus) + verificationSource = try container.decode(SolanaVerificationSource.self, forKey: .verificationSource) + priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) + balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) + } +} + +extension SolanaFungibleTokenBalance { + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + network = try container.decode(SolanaNetwork.self, forKey: .network) + accountAddress = try container.decode(String.self, forKey: .accountAddress) + tokenProgram = try container.decode(SolanaTokenProgram.self, forKey: .tokenProgram) + mintAddress = try container.decode(String.self, forKey: .mintAddress) + name = try container.decode(String.self, forKey: .name) + symbol = try container.decode(String.self, forKey: .symbol) + decimals = try container.decode(Int.self, forKey: .decimals) + balance = try container.decode(String.self, forKey: .balance) + formattedBalance = try container.decode(String.self, forKey: .formattedBalance) + imageUrl = try container.decodeNonEmptyString(forKey: .imageUrl) + metadataUri = try container.decodeNonEmptyString(forKey: .metadataUri) + verificationStatus = try container.decode(SolanaVerificationStatus.self, forKey: .verificationStatus) + verificationSource = try container.decode(SolanaVerificationSource.self, forKey: .verificationSource) + priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) + balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) + } +} + public enum SolanaBalance: Decodable, Sendable { case native(SolanaNativeBalance) case fungibleToken(SolanaFungibleTokenBalance) diff --git a/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift index 495ea6e..df0a2df 100644 --- a/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift +++ b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift @@ -91,15 +91,6 @@ private enum TronBalanceCodingKeys: String, CodingKey { case balanceUSD } -private extension KeyedDecodingContainer where Key == TronBalanceCodingKeys { - func decodeNonEmptyString(forKey key: Key) throws -> String? { - guard let value = try decodeIfPresent(String.self, forKey: key), !value.isEmpty else { - return nil - } - return value - } -} - public enum TronBalance: Decodable, Sendable { case native(TronNativeBalance) case fungibleToken(TronFungibleTokenBalance) diff --git a/Sources/OMSWallet/Utils/DecodableExtension.swift b/Sources/OMSWallet/Utils/DecodableExtension.swift index 79ec291..2509935 100644 --- a/Sources/OMSWallet/Utils/DecodableExtension.swift +++ b/Sources/OMSWallet/Utils/DecodableExtension.swift @@ -5,3 +5,13 @@ extension Decodable { try decoder.decode(Self.self, from: Data(jsonString.utf8)) } } + +extension KeyedDecodingContainer { + /// Decodes an optional string, treating an empty string as absent. + func decodeNonEmptyString(forKey key: Key) throws -> String? { + guard let value = try decodeIfPresent(String.self, forKey: key), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index c3c9cdb..56aa317 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -181,7 +181,7 @@ import Testing @Test func TestGetSolanaBalancesUsesSolanaGatewayAndDecodesStrictAssets() async throws { let recorder = IndexerRequestRecorder( responseBody: Data( - #"{"balances":[{"network":"solana:mainnet","accountAddress":"solana-wallet","assetType":"native","name":"Solana","symbol":"SOL","decimals":9,"balance":"4679287","formattedBalance":"0.004679287","verificationStatus":"unknown","verificationSource":"none"},{"network":"solana:mainnet","accountAddress":"solana-wallet","assetType":"fungible-token","tokenProgram":"spl-token","mintAddress":"usdc-mint","name":"USD Coin","symbol":"USDC","decimals":6,"balance":"4208117429","formattedBalance":"4208.117429","verificationStatus":"verified","verificationSource":"jupiter"}],"errors":[{"network":"solana:devnet","reason":"RPC unavailable"}]}"#.utf8 + #"{"balances":[{"network":"solana:mainnet","accountAddress":"solana-wallet","assetType":"native","name":"Solana","symbol":"SOL","decimals":9,"balance":"4679287","formattedBalance":"0.004679287","imageUrl":"","metadataUri":"","verificationStatus":"unknown","verificationSource":"none"},{"network":"solana:mainnet","accountAddress":"solana-wallet","assetType":"fungible-token","tokenProgram":"spl-token","mintAddress":"usdc-mint","name":"USD Coin","symbol":"USDC","decimals":6,"balance":"4208117429","formattedBalance":"4208.117429","verificationStatus":"verified","verificationSource":"jupiter"}],"errors":[{"network":"solana:devnet","reason":"RPC unavailable"}]}"#.utf8 ) ) let client = makeRecordingIndexerClient(recorder: recorder) From 0c720279db9ee28125e972069d433feebc33ad6f Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:58:32 +0300 Subject: [PATCH 10/35] refactor: check the session before the active wallet type requireActiveWalletType now runs the expiry/session check first and returns the active wallet, so verificationWalletAddress no longer calls requireActiveWalletId just for its side effect. An expired session with a wallet of another family now throws sessionExpired rather than validationError, matching the TypeScript SDK. --- Sources/OMSWallet/Clients/WalletOperations.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 93364d0..53b656e 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -830,13 +830,14 @@ extension WalletClient { } return walletAddress } - try requireActiveWalletType(walletType) - _ = try requireActiveWalletId() - return try requireActiveWalletAddress() + return try requireActiveWalletType(walletType).address } - /// Checks the stored wallet type, not the address shape. - func requireActiveWalletType(_ type: WalletType) throws { + /// Checks the session (expiring it if due) before the stored wallet type; the address shape is + /// not checked. + @discardableResult + func requireActiveWalletType(_ type: WalletType) throws -> Wallet { + _ = try requireActiveWalletId() guard let activeWallet else { throw OMSWalletError.sessionMissing() } @@ -846,6 +847,7 @@ extension WalletClient { message: "An active \(type.displayName) wallet is required" ) } + return activeWallet } } From b3ff71e57bc17e4bbe9faf43abead5beb7746f08 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:59:13 +0300 Subject: [PATCH 11/35] refactor: require expiresAt in the stored session record Every version 2 record is written with an expiry, so decode it as required and drop the redundant CodingKeys enum. A record without expiresAt is still discarded on load, as before. --- .../Models/Sessions/StorableCredentials.swift | 15 +++------------ .../Signer/WalletCredentialSession.swift | 6 +++--- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift index 1324692..30b56e6 100644 --- a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift +++ b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift @@ -9,14 +9,14 @@ struct StorableCredentials: Codable { let wallet: Wallet let signerCredentialId: String let alg: SigningAlgorithm - let expiresAt: String? + let expiresAt: String let auth: OMSWalletSessionAuth init( wallet: Wallet, signerCredentialId: String, alg: SigningAlgorithm, - expiresAt: String? = nil, + expiresAt: String, auth: OMSWalletSessionAuth ) { self.version = Self.currentVersion @@ -49,7 +49,7 @@ struct StorableCredentials: Codable { self.wallet = wallet self.signerCredentialId = try container.decode(String.self, forKey: .signerCredentialId) self.alg = try container.decode(SigningAlgorithm.self, forKey: .alg) - self.expiresAt = try container.decodeIfPresent(String.self, forKey: .expiresAt) + self.expiresAt = try container.decode(String.self, forKey: .expiresAt) self.auth = try container.decode(OMSWalletSessionAuth.self, forKey: .auth) } @@ -73,13 +73,4 @@ struct StorableCredentials: Codable { return false } } - - private enum CodingKeys: String, CodingKey { - case version - case wallet - case signerCredentialId - case alg - case expiresAt - case auth - } } diff --git a/Sources/OMSWallet/Signer/WalletCredentialSession.swift b/Sources/OMSWallet/Signer/WalletCredentialSession.swift index 3c61eed..e552c57 100644 --- a/Sources/OMSWallet/Signer/WalletCredentialSession.swift +++ b/Sources/OMSWallet/Signer/WalletCredentialSession.swift @@ -4,7 +4,7 @@ import Foundation final class WalletCredentialSession { struct WalletMetadata { let wallet: Wallet - let expiresAt: String? + let expiresAt: String let auth: OMSWalletSessionAuth } @@ -92,7 +92,7 @@ final class WalletCredentialSession { func persist( wallet: Wallet, - expiresAt: String?, + expiresAt: String, auth: OMSWalletSessionAuth ) throws { try withLock { @@ -164,7 +164,7 @@ final class WalletCredentialSession { return try body() } - private static func sessionIsExpired(expiresAt value: String?) -> Bool { + private static func sessionIsExpired(expiresAt value: String) -> Bool { guard let expiresAt = parseExpiresAt(value) else { return true } From 092e00ca5459089a0aa79618f90727396b8a61b5 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 18:59:33 +0300 Subject: [PATCH 12/35] refactor: remove the unused WalletType WaaS mappings Wallets map through networkFamily; WalletType.waasValue and WaasGenerated.WalletType.sdkValue had no callers. --- .../OMSWallet/Models/WaasModelMapping.swift | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/Sources/OMSWallet/Models/WaasModelMapping.swift b/Sources/OMSWallet/Models/WaasModelMapping.swift index 184b806..22622e3 100644 --- a/Sources/OMSWallet/Models/WaasModelMapping.swift +++ b/Sources/OMSWallet/Models/WaasModelMapping.swift @@ -52,36 +52,6 @@ extension Dictionary where Key == String, Value == JSONValue { } } -extension WalletType { - var waasValue: WaasGenerated.WalletType { - switch self { - case .ethereum: - return .ethereum - case .solana: - return .solana - case .tron: - return .tron - case .unknown(let value): - return .unknown(value) - } - } -} - -extension WaasGenerated.WalletType { - var sdkValue: WalletType { - switch self { - case .ethereum: - return .ethereum - case .solana: - return .solana - case .tron: - return .tron - case .unknown(let value): - return .unknown(value) - } - } -} - extension WaasGenerated.NetworkFamily { var sdkWalletType: WalletType? { switch self { From 4536dac410e2e05a60ba82910943154fb04639ed Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:00:28 +0300 Subject: [PATCH 13/35] refactor: share Solana and Tron fee-option balance enrichment The two gateway families now differ only in their indexer call; address deduplication and the FeeOptionWithBalance mapping are shared helpers. The EVM path uses the same dedupe and a plain [String: TokenBalance] map instead of a double optional. --- .../OMSWallet/Clients/WalletOperations.swift | 123 ++++++++---------- 1 file changed, 55 insertions(+), 68 deletions(-) diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 53b656e..e5d7e43 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -563,13 +563,7 @@ extension WalletClient { walletAddress: String, feeOptions: [FeeOption] ) async -> [FeeOptionWithBalance] { - let contractAddresses = feeOptions - .compactMap { normalizedAddress($0.token.contractAddress) } - .reduce(into: [String]()) { addresses, address in - if !addresses.contains(address) { - addresses.append(address) - } - } + let contractAddresses = uniqued(feeOptions.compactMap { normalizedAddress($0.token.contractAddress) }) let balances = try? await indexerClient.getBalances( GetBalancesParams( @@ -584,7 +578,7 @@ extension WalletClient { ? balances?.nativeBalances.first { $0.chainId == Int64(network.id) }.map(TokenBalance.native) : nil - var balancesByContract: [String: TokenBalance?] = [:] + var balancesByContract: [String: TokenBalance] = [:] for contractAddress in contractAddresses { balancesByContract[contractAddress] = balances?.balances.first { normalizedAddress($0.contractAddress) == contractAddress @@ -597,7 +591,7 @@ extension WalletClient { balance = nativeBalance } else { balance = normalizedAddress(feeOption.token.contractAddress) - .flatMap { balancesByContract[$0] ?? nil } + .flatMap { balancesByContract[$0] } } let decimals = feeOption.token.balanceDecimals @@ -617,50 +611,26 @@ extension WalletClient { walletAddress: String, feeOptions: [FeeOption] ) async -> [FeeOptionWithBalance] { - let mintAddresses = feeOptions - .filter { !$0.token.isNativeToken } - .compactMap { normalizedBase58Address($0.token.contractAddress) } - .reduce(into: [String]()) { addresses, address in - if !addresses.contains(address) { - addresses.append(address) - } - } - let includesNative = feeOptions.contains { $0.token.isNativeToken } let balances = try? await indexerClient.getSolanaBalances( GetSolanaBalancesParams( walletAddress: walletAddress, networks: [network], includeMetadata: false, - omitNativeBalances: !includesNative, - mintAddresses: mintAddresses + omitNativeBalances: !feeOptions.contains { $0.token.isNativeToken }, + mintAddresses: gatewayFeeTokenAddresses(feeOptions) ) ) - let nativeBalance = balances?.balances.first { balance in - if case .native(let value) = balance { - return value.network == network - } - return false - } - var balancesByMint: [String: SolanaBalance] = [:] - for balance in balances?.balances ?? [] { - if case .fungibleToken(let value) = balance, value.network == network { - balancesByMint[value.mintAddress] = balance + var native: GatewayFeeBalance? + var byAddress: [String: GatewayFeeBalance] = [:] + for balance in balances?.balances ?? [] where balance.network == network { + switch balance { + case .native: + native = native ?? GatewayFeeBalance(balance: balance.balance, decimals: balance.decimals) + case .fungibleToken(let value): + byAddress[value.mintAddress] = GatewayFeeBalance(balance: balance.balance, decimals: balance.decimals) } } - - return feeOptions.enumerated().map { index, feeOption in - let balance = feeOption.token.isNativeToken - ? nativeBalance - : normalizedBase58Address(feeOption.token.contractAddress).flatMap { balancesByMint[$0] } - let decimals = balance?.decimals ?? feeOption.token.decimals.map(Int.init) - return FeeOptionWithBalance( - feeOption: feeOption, - selection: FeeOptionSelection(feeOption: feeOption, index: UInt32(index)), - available: formatTokenAmount(balance?.balance, decimals: decimals), - availableRaw: balance?.balance, - decimals: decimals - ) - } + return feeOptionsWithGatewayBalances(feeOptions, native: native, byAddress: byAddress) } private func enrichTronFeeOptionsWithBalances( @@ -668,41 +638,46 @@ extension WalletClient { walletAddress: String, feeOptions: [FeeOption] ) async -> [FeeOptionWithBalance] { - let contractAddresses = feeOptions - .filter { !$0.token.isNativeToken } - .compactMap { normalizedBase58Address($0.token.contractAddress) } - .reduce(into: [String]()) { addresses, address in - if !addresses.contains(address) { - addresses.append(address) - } - } - let includesNative = feeOptions.contains { $0.token.isNativeToken } let balances = try? await indexerClient.getTronBalances( GetTronBalancesParams( walletAddress: walletAddress, networks: [network], includeMetadata: false, - omitNativeBalances: !includesNative, - contractAddresses: contractAddresses + omitNativeBalances: !feeOptions.contains { $0.token.isNativeToken }, + contractAddresses: gatewayFeeTokenAddresses(feeOptions) ) ) - let nativeBalance = balances?.balances.first { balance in - if case .native(let value) = balance { - return value.network == network - } - return false - } - var balancesByContract: [String: TronBalance] = [:] - for balance in balances?.balances ?? [] { - if case .fungibleToken(let value) = balance, value.network == network { - balancesByContract[value.contractAddress] = balance + var native: GatewayFeeBalance? + var byAddress: [String: GatewayFeeBalance] = [:] + for balance in balances?.balances ?? [] where balance.network == network { + switch balance { + case .native: + native = native ?? GatewayFeeBalance(balance: balance.balance, decimals: balance.decimals) + case .fungibleToken(let value): + byAddress[value.contractAddress] = GatewayFeeBalance(balance: balance.balance, decimals: balance.decimals) } } + return feeOptionsWithGatewayBalances(feeOptions, native: native, byAddress: byAddress) + } - return feeOptions.enumerated().map { index, feeOption in + /// Non-native fee token addresses to request from the Solana or Tron indexer gateway. + private func gatewayFeeTokenAddresses(_ feeOptions: [FeeOption]) -> [String] { + uniqued( + feeOptions + .filter { !$0.token.isNativeToken } + .compactMap { normalizedBase58Address($0.token.contractAddress) } + ) + } + + private func feeOptionsWithGatewayBalances( + _ feeOptions: [FeeOption], + native: GatewayFeeBalance?, + byAddress: [String: GatewayFeeBalance] + ) -> [FeeOptionWithBalance] { + feeOptions.enumerated().map { index, feeOption in let balance = feeOption.token.isNativeToken - ? nativeBalance - : normalizedBase58Address(feeOption.token.contractAddress).flatMap { balancesByContract[$0] } + ? native + : normalizedBase58Address(feeOption.token.contractAddress).flatMap { byAddress[$0] } let decimals = balance?.decimals ?? feeOption.token.decimals.map(Int.init) return FeeOptionWithBalance( feeOption: feeOption, @@ -912,6 +887,18 @@ private func normalizedAddress(_ address: String?) -> String? { return trimmed.lowercased() } +/// A Solana or Tron fee token balance in base units. +private struct GatewayFeeBalance { + let balance: String + let decimals: Int +} + +/// Removes duplicates, keeping the first occurrence of each value. +private func uniqued(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0).inserted } +} + private func normalizedBase58Address(_ address: String?) -> String? { guard let trimmed = address?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { From ba05d139953e53b3980d8132e3b5706ee32a41c3 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:01:36 +0300 Subject: [PATCH 14/35] refactor: derive walletId from the active wallet walletId was stored separately but always set alongside activeWallet. It is now computed from activeWallet, as in the Kotlin SDK, and tests seed the active wallet instead of a bare wallet ID. --- Sources/OMSWallet/Clients/WalletClient.swift | 18 +--- .../OMSWallet/Clients/WalletSessions.swift | 3 - .../ActiveWalletSessionTests.swift | 1 - Tests/OMSWalletTests/MockWalletTests.swift | 82 +++++++------------ .../PublicErrorContractsTests.swift | 10 +-- .../SignatureVerificationTests.swift | 1 - Tests/OMSWalletTests/TronWalletTests.swift | 2 - 7 files changed, 36 insertions(+), 81 deletions(-) diff --git a/Sources/OMSWallet/Clients/WalletClient.swift b/Sources/OMSWallet/Clients/WalletClient.swift index 3928a74..64ce1c1 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -96,7 +96,6 @@ public final class WalletClient: @unchecked Sendable { } } private var _activeWallet: Wallet? - private var _walletId: String var _sessionRevision: UInt64 = 0 private var _sessionExpiredObservers: [UUID: SessionExpiredObserver] = [:] private var _verifier = "" @@ -113,13 +112,9 @@ public final class WalletClient: @unchecked Sendable { withSessionLock { _activeWallet = newValue } } } + /// The active wallet's ID, or `""` when there is none. var walletId: String { - get { - withSessionLock { _walletId } - } - set { - withSessionLock { _walletId = newValue } - } + withSessionLock { _activeWallet?.id ?? "" } } var verifier: String { get { @@ -245,7 +240,6 @@ public final class WalletClient: @unchecked Sendable { ) } - self._walletId = "" self._activeWallet = nil self._sessionExpiresAt = nil self._sessionAuth = nil @@ -289,7 +283,6 @@ public final class WalletClient: @unchecked Sendable { let makeSignedClient = signedClientFactory ?? { _ in signedClient } self.signedClientFactory = makeSignedClient - self._walletId = "" self._activeWallet = nil self._sessionExpiresAt = nil self._sessionAuth = nil @@ -385,7 +378,7 @@ public final class WalletClient: @unchecked Sendable { throw OMSWalletError.sessionExpired() } let hasActiveSession = withSessionLock { () -> Bool in - let hasWallet = !_walletId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasWallet = !(_activeWallet?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasVerifiedAuth = !(_sessionExpiresAt ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return hasWallet || hasVerifiedAuth } @@ -399,9 +392,7 @@ public final class WalletClient: @unchecked Sendable { deliverSessionExpiredNotification(notification) throw OMSWalletError.sessionExpired() } - let walletId = withSessionLock { - _walletId.trimmingCharacters(in: .whitespacesAndNewlines) - } + let walletId = self.walletId.trimmingCharacters(in: .whitespacesAndNewlines) guard !walletId.isEmpty else { throw OMSWalletError.sessionMissing() } @@ -454,7 +445,6 @@ public final class WalletClient: @unchecked Sendable { ) self._latestSessionExpiredEvent = nil self._activeWallet = wallet - self._walletId = wallet.id self._sessionExpiresAt = sessionMetadata.expiresAt self._sessionAuth = sessionMetadata.auth } diff --git a/Sources/OMSWallet/Clients/WalletSessions.swift b/Sources/OMSWallet/Clients/WalletSessions.swift index be2097c..ff8b217 100644 --- a/Sources/OMSWallet/Clients/WalletSessions.swift +++ b/Sources/OMSWallet/Clients/WalletSessions.swift @@ -32,7 +32,6 @@ extension WalletClient { return } - walletId = restoredWallet.wallet.id activeWallet = restoredWallet.wallet sessionExpiresAt = restoredWallet.expiresAt sessionAuth = restoredWallet.auth @@ -100,7 +99,6 @@ extension WalletClient { activePendingWalletSelection = nil try? credentialSession.clearSignerKeepingCredentials() activeWallet = nil - walletId = "" verifier = "" challenge = "" pendingEmailAuth = nil @@ -239,7 +237,6 @@ extension WalletClient { self.activePendingWalletSelection = nil try self.credentialSession.clear() self.activeWallet = nil - self.walletId = "" self.verifier = "" self.challenge = "" self.pendingEmailAuth = nil diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift index 5cdd06d..aa91e7d 100644 --- a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -123,7 +123,6 @@ func TestDiscardsStoredSessionsWithInvalidWallets(walletJSON: String) throws { @Test func TestRejectsEthereumWalletResponsesWithNonHexAddresses() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 876e91a..68c50ef 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -235,7 +235,6 @@ private func isOidcAuth( @Test func TestWalletStartEmailAuthRejectsInvalidSessionLifetimeBeforeRequestOrSignOut() async throws { let fixture = makeMockWalletClient() let wallet = testWallet(id: "wallet-existing", address: "0x1111111111111111111111111111111111111111") - fixture.client.walletId = wallet.id fixture.client.activeWallet = activeTestWallet(wallet.address, id: wallet.id) do { @@ -2008,10 +2007,9 @@ private func waitForSessionExpiredEvent( #expect(fixture.transport.requestCount(for: path) == 0) } - let walletIdOnlyFixture = makeMockWalletClient() - walletIdOnlyFixture.client.walletId = "wallet-main" + let feeSelectorFixture = makeMockWalletClient() await expectNoAuthenticatedWalletSession { - try await walletIdOnlyFixture.client.sendTransaction( + try await feeSelectorFixture.client.sendTransaction( network: .amoy, to: "0xabc", value: "0", @@ -2019,7 +2017,7 @@ private func waitForSessionExpiredEvent( ) } await expectNoAuthenticatedWalletSession { - try await walletIdOnlyFixture.client.callContract( + try await feeSelectorFixture.client.callContract( network: .amoy, contractAddress: "0xcontract", method: "mint", @@ -2027,13 +2025,13 @@ private func waitForSessionExpiredEvent( selectFeeOption: .firstAvailable ) } - #expect(walletIdOnlyFixture.transport.requestCount(for: WaasAPI.PrepareEthereumTransaction.urlPath) == 0) - #expect(walletIdOnlyFixture.transport.requestCount(for: WaasAPI.PrepareEthereumContractCall.urlPath) == 0) + #expect(feeSelectorFixture.transport.requestCount(for: WaasAPI.PrepareEthereumTransaction.urlPath) == 0) + #expect(feeSelectorFixture.transport.requestCount(for: WaasAPI.PrepareEthereumContractCall.urlPath) == 0) } @Test func TestWalletListAccessPaginationHelpersUseWaasPages() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") let firstCredential = WaasCredentialInfo( credentialId: "credential-1", type: .direct, @@ -2097,7 +2095,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletListAccessReturnsCombinedCredentialPages() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") try fixture.transport.enqueue( WaasListAccessResponse( @@ -2143,8 +2141,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletOwnerSmartSessionFlowUsesPublicAndSignedWaasMethods() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") let transfer = WaasGenerated.Grant( kind: .nativeTransfer, nativeTransfer: WaasGenerated.NativeTransferGrant( @@ -2228,8 +2225,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSolanaOperationsUseSolanaRequestShapes() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "solana-wallet" - fixture.client.activeWallet = activeTestWallet("3gFktQX6vki5M2DzN8Y1ESPUJ4fJ8o6hVQWf8vYvPypD") + fixture.client.activeWallet = activeTestWallet("3gFktQX6vki5M2DzN8Y1ESPUJ4fJ8o6hVQWf8vYvPypD", id: "solana-wallet") try fixture.transport.enqueue( WaasGenerated.SignMessageResponse(signature: "solana-signature"), for: WaasAPI.SignMessage.urlPath @@ -2293,8 +2289,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSolanaFirstAvailableUsesIndexerBalances() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "solana-wallet" - fixture.client.activeWallet = activeTestWallet("4Nd1mYQbqjVU2aR7cJNPyqW9XjHnBYvWQd7ZxYxvT6uP") + fixture.client.activeWallet = activeTestWallet("4Nd1mYQbqjVU2aR7cJNPyqW9XjHnBYvWQd7ZxYxvT6uP", id: "solana-wallet") let usdcMint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" try fixture.indexerBackend.setSolanaBalancesResponse( """ @@ -2390,8 +2385,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionDefaultSelectsFirstFeeOptionIdentifierWithoutBalanceLookup() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2437,8 +2431,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletGetTransactionStatusKeepsCancellationError() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") fixture.transport.enqueueCancellation(for: WaasAPI.TransactionStatusMethod.urlPath) do { @@ -2452,8 +2445,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionFirstAvailableSelectsFirstFundedFeeOption() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2518,8 +2510,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionFirstAvailableRequiresFundedFeeOption() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2621,8 +2612,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionCustomSelectorReceivesFeeOptionBalances() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2705,8 +2695,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionUnsponsoredCustomSelectorRequiresSelection() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2739,8 +2728,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionSponsoredInvokesCustomFeeSelectorWithEmptyOptions() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2784,8 +2772,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionSponsoredContinuesWithFirstAvailable() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2815,8 +2802,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionSponsoredDoesNotExecuteWhenAcknowledgementThrows() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2848,8 +2834,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletCallContractReturnsSendTransactionResponse() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2897,8 +2882,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionCanSkipStatusPolling() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2930,8 +2914,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletCallContractCanSkipStatusPolling() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2965,8 +2948,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionMarksTimedOutWhenPollingDeadlineExpires() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3007,8 +2989,7 @@ private func waitForSessionExpiredEvent( for (index, status) in statuses.enumerated() { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( txnId: "txn-timeout-\(index)", @@ -3049,8 +3030,7 @@ private func waitForSessionExpiredEvent( for (index, options) in invalidOptions.enumerated() { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( txnId: "txn-invalid-polling-\(index)", @@ -3080,8 +3060,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionReturnsWhenPollingFindsHashBeforeExecuted() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3116,8 +3095,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionReturnsFailedStatusAsTerminal() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3152,8 +3130,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionExecutedFastPathAcceptsStatusHash() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3188,8 +3165,7 @@ private func waitForSessionExpiredEvent( @Test func TestWalletSendTransactionUnsponsoredWithoutFeeOptionsThrows() async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0xwallet") + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( diff --git a/Tests/OMSWalletTests/PublicErrorContractsTests.swift b/Tests/OMSWalletTests/PublicErrorContractsTests.swift index 4b09743..aa20e44 100644 --- a/Tests/OMSWalletTests/PublicErrorContractsTests.swift +++ b/Tests/OMSWalletTests/PublicErrorContractsTests.swift @@ -45,8 +45,7 @@ import Testing transport: transport ) ) - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") await expectPublicError( try await fixture.client.getWalletImportRecipientKey(cipherSuite: .p256Sha256Aes256Gcm), @@ -95,7 +94,6 @@ import Testing let fixture = makeMockWalletClient( walletImportClient: WaasClient(baseURL: "https://wallet-import.test", transport: importTransport) ) - fixture.client.walletId = "wallet-main" fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) @@ -377,8 +375,7 @@ import Testing let now = Date(timeIntervalSince1970: 1_800_000_000) let expiredFixture = makeMockWalletClient(currentDate: { now }) - expiredFixture.client.walletId = "wallet-main" - expiredFixture.client.activeWallet = activeTestWallet("0xwallet") + expiredFixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") expiredFixture.client.sessionExpiresAt = "2025-01-01T00:00:00Z" expiredFixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) @@ -950,8 +947,7 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda private func makeRestoredWalletClient() -> MockWalletClientFixture { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111") + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) return fixture diff --git a/Tests/OMSWalletTests/SignatureVerificationTests.swift b/Tests/OMSWalletTests/SignatureVerificationTests.swift index 4b7b669..bdd6389 100644 --- a/Tests/OMSWalletTests/SignatureVerificationTests.swift +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -118,7 +118,6 @@ enum SignatureVerificationMethod: CaseIterable, CustomTestStringConvertible { } private func activate(_ fixture: MockWalletClientFixture, type: WalletType, address: String) { - fixture.client.walletId = "wallet-active" fixture.client.activeWallet = Wallet(id: "wallet-active", type: type, address: address, keyOrigin: .enclave) fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift index d14adfb..347bce8 100644 --- a/Tests/OMSWalletTests/TronWalletTests.swift +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -28,7 +28,6 @@ private func makeActiveWalletFixture( address: String = tronWalletAddress ) -> MockWalletClientFixture { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-id" fixture.client.activeWallet = Wallet(id: "wallet-id", type: type, address: address, keyOrigin: .enclave) fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) @@ -515,7 +514,6 @@ func TestTronOperationsRejectNonTronWalletsBeforeAnyRequest(type: WalletType, ad let fixture = makeMockWalletClient( walletImportClient: WaasClient(baseURL: "https://wallet-import.test", transport: importTransport) ) - fixture.client.walletId = "wallet-main" fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) From a497ec30b3b6f5425cf97633519deaff86225ed6 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:02:25 +0300 Subject: [PATCH 15/35] refactor: remove unused SDK helpers and error cases Drop clearAllPendingOIDCRedirectAuth, ByteUtils.hexToBytes, the always-nil contractTypes/contractBlacklist EVM filter fields, the unused jsonString(pretty:) parameter, the never-thrown TransactionError.pollingTimedOut and .missingTransactionHash cases, and the unreachable second noFeeOptionsAvailable guard. Wire requests are unchanged. --- Sources/OMSWallet/Clients/IndexerClient.swift | 2 -- Sources/OMSWallet/Clients/WalletClient.swift | 4 ---- Sources/OMSWallet/Clients/WalletOperations.swift | 12 +----------- Sources/OMSWallet/Models/OMSWalletError.swift | 10 +--------- .../Models/Operations/TransactionError.swift | 6 ------ Sources/OMSWallet/Utils/ByteUtils.swift | 15 --------------- Sources/OMSWallet/Utils/EncodableExtension.swift | 6 ++---- Tests/OMSWalletTests/MockWalletTests.swift | 4 +--- 8 files changed, 5 insertions(+), 54 deletions(-) diff --git a/Sources/OMSWallet/Clients/IndexerClient.swift b/Sources/OMSWallet/Clients/IndexerClient.swift index 6c2fd5a..64f2106 100644 --- a/Sources/OMSWallet/Clients/IndexerClient.swift +++ b/Sources/OMSWallet/Clients/IndexerClient.swift @@ -371,9 +371,7 @@ private func indexerResponseUpstreamError( private struct TokenBalancesFilter: Encodable { let accountAddresses: [String] let contractStatus: ContractVerificationStatus? - let contractTypes: [String]? = nil let contractWhitelist: [String]? - let contractBlacklist: [String]? = nil let omitNativeBalances: Bool let omitPrices: Bool? let tokenIDs: [String]? diff --git a/Sources/OMSWallet/Clients/WalletClient.swift b/Sources/OMSWallet/Clients/WalletClient.swift index 64ce1c1..bda0afc 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -180,10 +180,6 @@ public final class WalletClient: @unchecked Sendable { } } - func clearAllPendingOIDCRedirectAuth() throws { - try withOIDCRedirectAuthProjectLock { try oidcRedirectAuthStore.clear() } - } - func withOIDCRedirectAuthOwnership( _ pending: PendingOIDCRedirectAuth, _ body: () throws -> T diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index e5d7e43..468439d 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -513,10 +513,7 @@ extension WalletClient { } guard let feeOptionSelector else { - guard let feeOptionSelection = feeOptions.defaultSelection() else { - throw TransactionError.noFeeOptionsAvailable - } - return feeOptionSelection + return FeeOptionSelection(feeOption: feeOptions[0], index: 0) } let options: [FeeOptionWithBalance] @@ -854,13 +851,6 @@ private func isASCIILetter(_ byte: UInt8) -> Bool { (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) } -@available(macOS 12.0, iOS 15.0, *) -private extension Array where Element == FeeOption { - func defaultSelection() -> FeeOptionSelection? { - first.map { FeeOptionSelection(feeOption: $0, index: 0) } - } -} - private extension FeeToken { var isNativeToken: Bool { type.caseInsensitiveCompare("native") == .orderedSame diff --git a/Sources/OMSWallet/Models/OMSWalletError.swift b/Sources/OMSWallet/Models/OMSWalletError.swift index 24ce25a..9fea031 100644 --- a/Sources/OMSWallet/Models/OMSWalletError.swift +++ b/Sources/OMSWallet/Models/OMSWalletError.swift @@ -413,15 +413,7 @@ private extension WebRPCError { private extension TransactionError { func toOMSWalletError(operation: OMSWalletOperation) -> OMSWalletError { switch self { - case .pollingTimedOut: - return OMSWalletError( - code: .transactionStatusLookupFailed, - message: localizedDescription, - operation: operation, - retryable: true, - underlyingError: self - ) - case .noFeeOptionsAvailable, .noFeeOptionSelected, .missingTransactionHash, .invalidPollingOption: + case .noFeeOptionsAvailable, .noFeeOptionSelected, .invalidPollingOption: return OMSWalletError( code: .validationError, message: localizedDescription, diff --git a/Sources/OMSWallet/Models/Operations/TransactionError.swift b/Sources/OMSWallet/Models/Operations/TransactionError.swift index 027f667..169de8d 100644 --- a/Sources/OMSWallet/Models/Operations/TransactionError.swift +++ b/Sources/OMSWallet/Models/Operations/TransactionError.swift @@ -3,9 +3,7 @@ import Foundation enum TransactionError: Error { case noFeeOptionsAvailable case noFeeOptionSelected - case missingTransactionHash case transactionFailed(status: TransactionStatus) - case pollingTimedOut case invalidPollingOption(String) } @@ -16,12 +14,8 @@ extension TransactionError: LocalizedError { return "No fee options are available for this transaction." case .noFeeOptionSelected: return "No fee option was selected for this transaction." - case .missingTransactionHash: - return "Transaction status response is missing a transaction hash." case .transactionFailed(let status): return "Transaction failed with status: \(status)." - case .pollingTimedOut: - return "Transaction polling timed out." case .invalidPollingOption(let message): return message } diff --git a/Sources/OMSWallet/Utils/ByteUtils.swift b/Sources/OMSWallet/Utils/ByteUtils.swift index fad3e9a..48153b4 100644 --- a/Sources/OMSWallet/Utils/ByteUtils.swift +++ b/Sources/OMSWallet/Utils/ByteUtils.swift @@ -5,19 +5,4 @@ class ByteUtils { public static func bytesToHex(data: [UInt8]) -> String { data.map { String(format: "%02x", $0) }.joined() } - - /// Converts a hexadecimal string to a byte array. - /// - Parameter hex: A hex string (case-insensitive, with or without `0x` prefix). - /// - Returns: The corresponding bytes, or an empty array if the string is invalid. - public static func hexToBytes(hex: String) -> [UInt8] { - var sanitised = hex.lowercased() - if sanitised.hasPrefix("0x") { sanitised = String(sanitised.dropFirst(2)) } - guard sanitised.count.isMultiple(of: 2) else { return [] } - - return stride(from: 0, to: sanitised.count, by: 2).compactMap { i in - let start = sanitised.index(sanitised.startIndex, offsetBy: i) - let end = sanitised.index(start, offsetBy: 2) - return UInt8(sanitised[start.. String { - let encoder = JSONEncoder() - if pretty { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } - let data = try encoder.encode(self) + func jsonString() throws -> String { + let data = try JSONEncoder().encode(self) return String(decoding: data, as: UTF8.self) } } diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 68c50ef..2f89ff4 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -3224,9 +3224,7 @@ private func isTransactionError(_ error: (any Error)?, _ expected: TransactionEr switch (transactionError, expected) { case (.noFeeOptionsAvailable, .noFeeOptionsAvailable), - (.noFeeOptionSelected, .noFeeOptionSelected), - (.missingTransactionHash, .missingTransactionHash), - (.pollingTimedOut, .pollingTimedOut): + (.noFeeOptionSelected, .noFeeOptionSelected): return true case (.transactionFailed(let actual), .transactionFailed(let expected)): return actual == expected From bc7a10c51ea0183841d40a546d9159cd0e89efd6 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:03:10 +0300 Subject: [PATCH 16/35] refactor: wrap completeWalletAuth's sign-out-on-failure steps once signOutOnFailure now takes the OIDC redirect ownership and skips the sign-out itself when a redirect callback owns the flow, replacing three copies of the ownership == nil branch. --- Sources/OMSWallet/Clients/WalletAuth.swift | 57 ++++++++-------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index 4e48f43..93d76dc 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -585,13 +585,8 @@ extension WalletClient { } } - let wallets: [Wallet] - if oidcRedirectAuthOwnership == nil { - wallets = try await signOutOnFailure { - try await walletsFromAuthResponse(response) - } - } else { - wallets = try await walletsFromAuthResponse(response) + let wallets = try await signOutOnFailure(unlessOwnedBy: oidcRedirectAuthOwnership) { + try await walletsFromAuthResponse(response) } try withOptionalOIDCRedirectAuthOwnership(oidcRedirectAuthOwnership) { try requireCurrentSessionRevision(requiredSessionRevision) @@ -614,41 +609,21 @@ extension WalletClient { ) } - let activated: WalletActivationResult - if let selectedWallet = candidateWallets.first { - if oidcRedirectAuthOwnership == nil { - activated = try await signOutOnFailure { - try await useWallet( - walletId: selectedWallet.id, - sessionMetadata: sessionMetadata, - requiredSessionRevision: requiredSessionRevision - ) - } - } else { - activated = try await useWallet( + let activated = try await signOutOnFailure(unlessOwnedBy: oidcRedirectAuthOwnership) { + if let selectedWallet = candidateWallets.first { + return try await useWallet( walletId: selectedWallet.id, sessionMetadata: sessionMetadata, requiredSessionRevision: requiredSessionRevision, oidcRedirectAuthOwnership: oidcRedirectAuthOwnership ) } - } else { - if oidcRedirectAuthOwnership == nil { - activated = try await signOutOnFailure { - try await createWallet( - walletType: walletType, - sessionMetadata: sessionMetadata, - requiredSessionRevision: requiredSessionRevision - ) - } - } else { - activated = try await createWallet( - walletType: walletType, - sessionMetadata: sessionMetadata, - requiredSessionRevision: requiredSessionRevision, - oidcRedirectAuthOwnership: oidcRedirectAuthOwnership - ) - } + return try await createWallet( + walletType: walletType, + sessionMetadata: sessionMetadata, + requiredSessionRevision: requiredSessionRevision, + oidcRedirectAuthOwnership: oidcRedirectAuthOwnership + ) } return .walletSelected( @@ -743,7 +718,15 @@ extension WalletClient { } } - private func signOutOnFailure(_ operation: () async throws -> T) async throws -> T { + /// Signs out when `operation` fails, except during an OIDC redirect callback (`ownership` + /// non-`nil`), whose caller clears the session itself. + private func signOutOnFailure( + unlessOwnedBy ownership: PendingOIDCRedirectAuth?, + _ operation: () async throws -> T + ) async throws -> T { + guard ownership == nil else { + return try await operation() + } do { return try await operation() } catch let error as CancellationError { From 96c10afafa3e70fafca45a0b47bcc0c98579f4a7 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:03:27 +0300 Subject: [PATCH 17/35] refactor: wrap the clearSession storage error once The storage-error wrapping moves into the clearState closure, so the OIDC-store and plain paths no longer repeat it. --- .../OMSWallet/Clients/WalletSessions.swift | 94 ++++++++----------- 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/Sources/OMSWallet/Clients/WalletSessions.swift b/Sources/OMSWallet/Clients/WalletSessions.swift index ff8b217..e232cfa 100644 --- a/Sources/OMSWallet/Clients/WalletSessions.swift +++ b/Sources/OMSWallet/Clients/WalletSessions.swift @@ -225,65 +225,51 @@ extension WalletClient { requiredSessionRevision: UInt64? = nil ) throws -> Bool { let clearState = { - try self.withSessionLock { - if let requiredSessionRevision, - self._sessionRevision != requiredSessionRevision { - return false + do { + return try self.withSessionLock { + if let requiredSessionRevision, + self._sessionRevision != requiredSessionRevision { + return false + } + self._sessionRevision += 1 + self.latestSessionExpiredEvent = nil + self.sessionExpiryTask?.cancel() + self.sessionExpiryTask = nil + self.activePendingWalletSelection = nil + try self.credentialSession.clear() + self.activeWallet = nil + self.verifier = "" + self.challenge = "" + self.pendingEmailAuth = nil + self.sessionExpiresAt = nil + self.sessionAuth = nil + self.signedClient = self.signedClientFactory(self.credentialSession.signer) + return true } - self._sessionRevision += 1 - self.latestSessionExpiredEvent = nil - self.sessionExpiryTask?.cancel() - self.sessionExpiryTask = nil - self.activePendingWalletSelection = nil - try self.credentialSession.clear() - self.activeWallet = nil - self.verifier = "" - self.challenge = "" - self.pendingEmailAuth = nil - self.sessionExpiresAt = nil - self.sessionAuth = nil - self.signedClient = self.signedClientFactory(self.credentialSession.signer) - return true - } - } - if clearOidcRedirectAuth { - return try withOIDCRedirectAuthProjectLock { - let cleared: Bool - do { - cleared = try clearState() - } catch { - throw OMSWalletError.storageError( - message: "Wallet session cleanup failed.", - underlyingError: error - ) - } - guard cleared else { - return false - } - do { - try oidcRedirectAuthStore.clear() - } catch { - throw OMSWalletError.storageError( - message: "OIDC redirect auth state cleanup failed.", - underlyingError: error - ) - } - return true + } catch { + throw OMSWalletError.storageError( + message: "Wallet session cleanup failed.", + underlyingError: error + ) } } - let cleared: Bool - do { - cleared = try clearState() - } catch { - throw OMSWalletError.storageError( - message: "Wallet session cleanup failed.", - underlyingError: error - ) + guard clearOidcRedirectAuth else { + return try clearState() } - guard cleared else { - return false + return try withOIDCRedirectAuthProjectLock { + guard try clearState() else { + return false + } + do { + try oidcRedirectAuthStore.clear() + } catch { + throw OMSWalletError.storageError( + message: "OIDC redirect auth state cleanup failed.", + underlyingError: error + ) + } + return true } - return true } /// Returns display metadata for a remote credential before the owner approves access. From 1ce7f952911ed4990c5383813268b8c2bcfe18e4 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:03:47 +0300 Subject: [PATCH 18/35] refactor: reuse OMSWalletSession.parseDate in the credential session WalletCredentialSession had a line-for-line copy of the ISO-8601 parser and a makeCredentialSigner wrapper around signerFactory; both are gone. The restore-time expiry clock is unchanged. --- .../Signer/WalletCredentialSession.swift | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/Sources/OMSWallet/Signer/WalletCredentialSession.swift b/Sources/OMSWallet/Signer/WalletCredentialSession.swift index e552c57..1343e2e 100644 --- a/Sources/OMSWallet/Signer/WalletCredentialSession.swift +++ b/Sources/OMSWallet/Signer/WalletCredentialSession.swift @@ -62,7 +62,7 @@ final class WalletCredentialSession { return nil } - let candidateSigner = makeCredentialSigner() + let candidateSigner = signerFactory() do { guard try candidateSigner.hasCredential() else { _ = try? keychain.delete(forKey: credentialsStorageKey) @@ -135,10 +135,6 @@ final class WalletCredentialSession { } } - private func makeCredentialSigner() -> any CredentialSigner { - signerFactory() - } - private func signerMatchesStoredCredential( _ signer: any CredentialSigner, credentials: StorableCredentials @@ -151,7 +147,7 @@ final class WalletCredentialSession { } private func clearStoredSession() { - _ = try? makeCredentialSigner().clear() + _ = try? signerFactory().clear() _ = try? keychain.delete(forKey: credentialsStorageKey) currentSigner = signerFactory() } @@ -165,27 +161,13 @@ final class WalletCredentialSession { } private static func sessionIsExpired(expiresAt value: String) -> Bool { - guard let expiresAt = parseExpiresAt(value) else { + guard let expiresAt = OMSWalletSession.parseDate(value) else { return true } return expiresAt <= Date() } - private static func parseExpiresAt(_ value: String?) -> Date? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: value) { - return date - } - - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: value) - } - private static func makeDefaultCredentialSigner( projectId: String, environment: OMSWalletEnvironment, From 77b8a8623cd8130514991bd9a4f45b3b473f7a3d Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:04:07 +0300 Subject: [PATCH 19/35] refactor: use CryptoKit HKDF for wallet-import HPKE Replace the hand-rolled HMAC extract/expand loop with HKDF, which is available on the package's macOS 12 / iOS 15 floor and produces the same output; the RFC 9180 vector tests still pass. --- Sources/OMSWallet/WalletImport.swift | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/Sources/OMSWallet/WalletImport.swift b/Sources/OMSWallet/WalletImport.swift index 927cb32..ab82946 100644 --- a/Sources/OMSWallet/WalletImport.swift +++ b/Sources/OMSWallet/WalletImport.swift @@ -259,24 +259,13 @@ enum P256HPKE { } private static func hkdfExtract(salt: Data, ikm: Data) -> Data { - let key = SymmetricKey(data: salt.isEmpty ? Data(repeating: 0, count: 32) : salt) - return Data(HMAC.authenticationCode(for: ikm, using: key)) + Data(HKDF.extract(inputKeyMaterial: SymmetricKey(data: ikm), salt: salt)) } private static func hkdfExpand(prk: Data, info: Data, length: Int) throws -> Data { guard length <= 255 * 32 else { throw OMSWalletError(code: .validationError, message: "HPKE output is too long") } - var output = Data() - var previous = Data() - var counter: UInt8 = 1 - while output.count < length { - previous = Data(HMAC.authenticationCode( - for: previous + info + Data([counter]), - using: SymmetricKey(data: prk) - )) - output += previous - counter &+= 1 - } - return output.prefix(length) + return HKDF.expand(pseudoRandomKey: prk, info: info, outputByteCount: length) + .withUnsafeBytes { Data($0) } } private static func uint16(_ value: UInt16) -> Data { From 773cfc140466f18b0bd79f49cc7eb98fa26af41e Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:04:40 +0300 Subject: [PATCH 20/35] refactor(tests): drop test aliases that duplicate the SDK's WaaS aliases @testable import already exposes the 44 aliases from Sources/OMSWallet/Generated/WaasGeneratedAliases.swift; keep only the test-only ones. --- .../OMSWalletTests/WaasGeneratedAliases.swift | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/Tests/OMSWalletTests/WaasGeneratedAliases.swift b/Tests/OMSWalletTests/WaasGeneratedAliases.swift index 4e31acb..c7e69f5 100644 --- a/Tests/OMSWalletTests/WaasGeneratedAliases.swift +++ b/Tests/OMSWalletTests/WaasGeneratedAliases.swift @@ -1,55 +1,9 @@ @testable import OMSWallet -typealias WaasAPI = WaasGenerated.WaasAPI -typealias WaasPublicAPI = WaasGenerated.WaasPublicAPI -typealias WaasClient = WaasGenerated.WaasClient -typealias WaasPublicClient = WaasGenerated.WaasPublicClient -typealias WebRPCJSON = WaasGenerated.WebRPCJSON -typealias WebRPCHTTPResponse = WaasGenerated.WebRPCHTTPResponse -typealias WebRPCTransport = WaasGenerated.WebRPCTransport -typealias WebRPCTransportError = WaasGenerated.WebRPCTransportError typealias WebRPCGeneratedVersions = WaasGenerated.WebRPCGeneratedVersions -typealias WebRPCErrorKind = WaasGenerated.WebRPCErrorKind let WEBRPC_HEADER_VALUE = WaasGenerated.WEBRPC_HEADER_VALUE -typealias SigningAlgorithm = WaasGenerated.SigningAlgorithm -typealias AuthMode = WaasGenerated.AuthMode -typealias Identity = WaasGenerated.Identity -typealias IdentityType = WaasGenerated.IdentityType - -typealias CommitVerifierRequest = WaasGenerated.CommitVerifierRequest -typealias CommitVerifierResponse = WaasGenerated.CommitVerifierResponse -typealias CompleteAuthRequest = WaasGenerated.CompleteAuthRequest -typealias CompleteAuthResponse = WaasGenerated.CompleteAuthResponse -typealias CreateWalletRequest = WaasGenerated.CreateWalletRequest -typealias CreateWalletResponse = WaasGenerated.CreateWalletResponse -typealias ExecuteRequest = WaasGenerated.ExecuteRequest -typealias ExecuteResponse = WaasGenerated.ExecuteResponse -typealias GetIDTokenRequest = WaasGenerated.GetIDTokenRequest -typealias GetIDTokenResponse = WaasGenerated.GetIDTokenResponse -typealias IsValidMessageSignatureRequest = WaasGenerated.IsValidMessageSignatureRequest -typealias IsValidMessageSignatureResponse = WaasGenerated.IsValidMessageSignatureResponse -typealias IsValidTypedDataSignatureRequest = WaasGenerated.IsValidTypedDataSignatureRequest -typealias IsValidTypedDataSignatureResponse = WaasGenerated.IsValidTypedDataSignatureResponse -typealias ListAccessRequest = WaasGenerated.ListAccessRequest -typealias ListWalletsRequest = WaasGenerated.ListWalletsRequest -typealias ListWalletsResponse = WaasGenerated.ListWalletsResponse -typealias PrepareEthereumContractCallRequest = WaasGenerated.PrepareEthereumContractCallRequest -typealias PrepareEthereumTransactionRequest = WaasGenerated.PrepareEthereumTransactionRequest -typealias PrepareResponse = WaasGenerated.PrepareResponse -typealias PrepareTronContractCallRequest = WaasGenerated.PrepareTronContractCallRequest -typealias PrepareTronTransactionRequest = WaasGenerated.PrepareTronTransactionRequest -typealias RevokeAccessRequest = WaasGenerated.RevokeAccessRequest -typealias RevokeAccessResponse = WaasGenerated.RevokeAccessResponse -typealias SignMessageRequest = WaasGenerated.SignMessageRequest -typealias SignMessageResponse = WaasGenerated.SignMessageResponse -typealias SignTypedDataRequest = WaasGenerated.SignTypedDataRequest -typealias SignTypedDataResponse = WaasGenerated.SignTypedDataResponse -typealias TransactionStatusRequest = WaasGenerated.TransactionStatusRequest -typealias UseWalletRequest = WaasGenerated.UseWalletRequest -typealias UseWalletResponse = WaasGenerated.UseWalletResponse - typealias WaasCredentialInfo = WaasGenerated.CredentialInfo typealias WaasFeeOption = WaasGenerated.FeeOption typealias WaasFeeToken = WaasGenerated.FeeToken From 2d72fa18a69079385fc5d32a992a06c0e04162c6 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:06:17 +0300 Subject: [PATCH 21/35] chore(scripts): derive the generated-WaaS leak list from waas.gen.swift The forbidden-symbol pattern listed nine request types that no longer exist and misspelled GetIDTokenRequest, while current request types were missing. Request type names are now read from the generated client; the one-item expected-error loop is a plain check. --- ...blic-api-does-not-expose-generated-waas.sh | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/check-public-api-does-not-expose-generated-waas.sh b/scripts/check-public-api-does-not-expose-generated-waas.sh index da5720e..6f63bc5 100755 --- a/scripts/check-public-api-does-not-expose-generated-waas.sh +++ b/scripts/check-public-api-does-not-expose-generated-waas.sh @@ -42,7 +42,18 @@ elif ! diff -u "$public_api_baseline" "$public_api_actual"; then exit 1 fi -forbidden_symbol_pattern='WaasGenerated|OMSWalletWaas|WebRPC[A-Za-z0-9_]*|Waas(API|PublicAPI|Client|PublicClient)|SigningAlgorithm|IdentityType|(^|[^A-Za-z0-9_])AuthMode([^A-Za-z0-9_]|$)|StartEmailAuthRequest|CompleteEmailAuthRequest|FederateAccountRequest|ListWalletsRequest|CreateWalletRequest|SignMessageRequest|SignTypedDataRequest|ExecuteRequest|PrepareTransactionRequest|PrepareCallContractRequest|GetIdTokenRequest|RevokeAccessRequest|ListAccessRequest|IntentRegistrationRequest|GetTransactionStatusRequest' +# Request types are read from the generated client so the list tracks WaaS regenerations. +generated_request_types="$( + grep -oE '(struct|enum) [A-Za-z0-9_]+Request([^A-Za-z0-9_]|$)' Sources/OMSWallet/Generated/waas.gen.swift \ + | awk '{ sub(/[^A-Za-z0-9_]$/, "", $2); print $2 }' \ + | LC_ALL=C sort -u \ + | paste -sd '|' - +)" +if [[ -z "$generated_request_types" ]]; then + echo "No generated WaaS request types were found in waas.gen.swift." >&2 + exit 1 +fi +forbidden_symbol_pattern="WaasGenerated|WebRPC[A-Za-z0-9_]*|Waas(API|PublicAPI|Client|PublicClient)|SigningAlgorithm|IdentityType|(^|[^A-Za-z0-9_])AuthMode([^A-Za-z0-9_]|\$)|${generated_request_types}" matches="$(grep -Eoh "$forbidden_symbol_pattern" "$symbol_graph" | sort -u || true)" if [[ -n "$matches" ]]; then echo "Generated WaaS symbols leaked into the public OMSWallet symbol graph:" >&2 @@ -124,13 +135,11 @@ if [[ "$negative_status" -eq 0 ]]; then exit 1 fi -for expected in "WaasGenerated"; do - if [[ "$negative_output" != *"cannot find '$expected' in scope"* ]]; then - echo "External generated-symbol check failed for an unexpected reason." >&2 - echo "$negative_output" >&2 - exit 1 - fi -done +if [[ "$negative_output" != *"cannot find 'WaasGenerated' in scope"* ]]; then + echo "External generated-symbol check failed for an unexpected reason." >&2 + echo "$negative_output" >&2 + exit 1 +fi expect_external_build_failure() { local label="$1" From 4e5ef641ae8982bb56511e2ef6a81ab06871c637 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 8 Oct 2026 19:07:33 +0300 Subject: [PATCH 22/35] chore(demo): remove dead demo helpers and use SDK constants - sdk-demo uses WalletClient.maxSessionLifetimeSeconds and Network.nativeTokenSymbol instead of local copies. - sdk-demo AppError drops a cancellation branch and an empty-details check that could not run. - trails-actions drops the unused formatSessionDate. --- Examples/sdk-demo/oms-wallet-demo/AppError.swift | 8 +------- Examples/sdk-demo/oms-wallet-demo/ContentView.swift | 12 +++--------- .../trails-actions/TrailsDemoModels.swift | 5 ----- 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/Examples/sdk-demo/oms-wallet-demo/AppError.swift b/Examples/sdk-demo/oms-wallet-demo/AppError.swift index 12a01ec..5b7c2a1 100644 --- a/Examples/sdk-demo/oms-wallet-demo/AppError.swift +++ b/Examples/sdk-demo/oms-wallet-demo/AppError.swift @@ -17,10 +17,6 @@ struct GenericAppError: Identifiable { } private func errorMessage(for error: Error) -> String { - if isCancellation(error) { - return "The operation was cancelled." - } - if let error = error as? OMSWalletError { return errorMessage(for: error) } @@ -70,9 +66,7 @@ private func errorMessage(for error: OMSWalletError) -> String { details.append("Underlying error: \(String(describing: underlyingError))") } - if !details.isEmpty { - sections.append(details.joined(separator: "\n")) - } + sections.append(details.joined(separator: "\n")) return sections.joined(separator: "\n\n") } diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index 5d4030d..3fdf157 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -50,8 +50,6 @@ struct SessionExpiredPrompt: Identifiable { } } -private let maxSessionLifetimeSeconds: UInt32 = 2_592_000 - private enum DemoAuthError: Error, LocalizedError { case invalidAuthorizationURL case invalidSessionLifetime @@ -198,10 +196,6 @@ private func formatUSDCBalance(_ raw: String) -> String { return "\(wholeOut).\(frac)" } -private func nativeTokenSymbol(for network: Network) -> String { - network.nativeTokenSymbol -} - private func formatNativeTokenBalance(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "0" } @@ -281,7 +275,7 @@ final class AppViewModel: ObservableObject { let trimmed = sessionLifetimeText.trimmingCharacters(in: .whitespacesAndNewlines) guard let seconds = UInt32(trimmed), seconds >= 1, - seconds <= maxSessionLifetimeSeconds else { + seconds <= WalletClient.maxSessionLifetimeSeconds else { return nil } return seconds @@ -1352,7 +1346,7 @@ struct WalletWindow: View { .foregroundStyle(DesignTokens.Color.primaryText) .lineLimit(1) .minimumScaleFactor(0.8) - Text(nativeTokenSymbol(for: selectedNetwork)) + Text(selectedNetwork.nativeTokenSymbol) .font(.caption) .foregroundStyle(DesignTokens.Color.secondaryText) } @@ -1369,7 +1363,7 @@ struct WalletWindow: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(DesignTokens.Color.primaryText) .monospacedDigit() - Text(nativeTokenSymbol(for: selectedNetwork)) + Text(selectedNetwork.nativeTokenSymbol) .font(.caption) .foregroundStyle(DesignTokens.Color.secondaryText) .monospacedDigit() diff --git a/Examples/trails-actions/trails-actions/TrailsDemoModels.swift b/Examples/trails-actions/trails-actions/TrailsDemoModels.swift index abc8476..4a01bff 100644 --- a/Examples/trails-actions/trails-actions/TrailsDemoModels.swift +++ b/Examples/trails-actions/trails-actions/TrailsDemoModels.swift @@ -328,11 +328,6 @@ func collapsedAddress(_ value: String) -> String { return "\(value.prefix(6))...\(value.suffix(4))" } -func formatSessionDate(_ date: Date?) -> String { - guard let date else { return "Unknown" } - return date.formatted(date: .abbreviated, time: .shortened) -} - func normalizedUnsignedInteger(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty, From 8f7ae1e3305d9588ab9a399983ea07050b3e7945 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 9 Oct 2026 12:57:17 +0300 Subject: [PATCH 23/35] refactor!: remove backward-compatibility leftovers --- MIGRATION.md | 6 ++++++ README.md | 4 ++-- .../Models/Indexer/TokenBalance.swift | 18 ++---------------- .../Models/Indexer/TokenMetadata.swift | 13 ++----------- .../Models/Indexer/TokenMetadataAsset.swift | 4 +--- .../OMSWallet/Models/Indexer/Transaction.swift | 8 ++------ Tests/OMSWalletTests/IndexerTests.swift | 6 +++--- 7 files changed, 18 insertions(+), 41 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e6d5c1c..bf78644 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -277,6 +277,12 @@ declare members with these names on those types. `SolanaBalance` now decodes an empty `imageUrl` or `metadataUri` as `nil`, as `TronBalance` does. +### Indexer model JSON keys + +Indexer models now encode and decode only the indexer gateway keys: `TransactionTransfer` uses +`tokenIds`, `TokenMetadata` and `TokenMetadataAsset` use `tokenId`, `Transaction` uses `metaTxnID`, +and `NativeTokenBalance` uses `balance`. + ## 0.3.0 ### Wallet types and key origin diff --git a/README.md b/README.md index cbc73ea..a1ca58e 100644 --- a/README.md +++ b/README.md @@ -882,8 +882,8 @@ try await omsWallet.wallet.revokeAccess( See [API.md](./API.md) for the full method and type reference. -When upgrading from `0.2.0`, see [MIGRATION.md](./MIGRATION.md) for the breaking changes in -`0.3.0`. +When upgrading from `0.3.x`, see [MIGRATION.md](./MIGRATION.md) for the breaking changes in +`0.4.0`. ## Publishing diff --git a/Sources/OMSWallet/Models/Indexer/TokenBalance.swift b/Sources/OMSWallet/Models/Indexer/TokenBalance.swift index b985947..891a681 100644 --- a/Sources/OMSWallet/Models/Indexer/TokenBalance.swift +++ b/Sources/OMSWallet/Models/Indexer/TokenBalance.swift @@ -35,7 +35,6 @@ public struct NativeTokenBalance: Codable, Sendable { case name case symbol case balance - case balanceWei case chainId case balanceUSD case priceUSD @@ -48,8 +47,7 @@ public struct NativeTokenBalance: Codable, Sendable { self.accountAddress = try container.decode(String.self, forKey: .accountAddress) self.name = try container.decode(String.self, forKey: .name) self.symbol = try container.decode(String.self, forKey: .symbol) - self.balance = try container.decodeIfPresent(String.self, forKey: .balance) - ?? container.decode(String.self, forKey: .balanceWei) + self.balance = try container.decode(String.self, forKey: .balance) self.chainId = try container.decode(Int64.self, forKey: .chainId) self.balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) self.priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) @@ -124,7 +122,6 @@ public struct ContractTokenBalance: Codable, Sendable { case contractType case contractAddress case accountAddress - case tokenId case tokenID case balance case balanceUSD @@ -144,18 +141,7 @@ public struct ContractTokenBalance: Codable, Sendable { self.contractType = try container.decode(String.self, forKey: .contractType) self.contractAddress = try container.decode(String.self, forKey: .contractAddress) self.accountAddress = try container.decode(String.self, forKey: .accountAddress) - if let tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) - ?? container.decodeIfPresent(String.self, forKey: .tokenID) { - self.tokenId = tokenId - } else { - throw DecodingError.keyNotFound( - CodingKeys.tokenID, - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Missing tokenId" - ) - ) - } + self.tokenId = try container.decode(String.self, forKey: .tokenID) self.balance = try container.decode(String.self, forKey: .balance) self.balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) self.priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) diff --git a/Sources/OMSWallet/Models/Indexer/TokenMetadata.swift b/Sources/OMSWallet/Models/Indexer/TokenMetadata.swift index bb2c4d3..490c58c 100644 --- a/Sources/OMSWallet/Models/Indexer/TokenMetadata.swift +++ b/Sources/OMSWallet/Models/Indexer/TokenMetadata.swift @@ -71,7 +71,6 @@ public struct TokenMetadata: Codable, Sendable { case chainId case contractAddress case tokenId - case tokenID case source case name case description @@ -96,15 +95,7 @@ public struct TokenMetadata: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) self.chainId = try container.decodeIfPresent(Int64.self, forKey: .chainId) self.contractAddress = try container.decodeIfPresent(String.self, forKey: .contractAddress) - if let tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) - ?? container.decodeIfPresent(String.self, forKey: .tokenID) { - self.tokenId = tokenId - } else { - throw DecodingError.keyNotFound( - CodingKeys.tokenID, - DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Missing tokenId") - ) - } + self.tokenId = try container.decode(String.self, forKey: .tokenId) self.source = try container.decode(String.self, forKey: .source) self.name = try container.decode(String.self, forKey: .name) self.description = try container.decodeIfPresent(String.self, forKey: .description) @@ -129,7 +120,7 @@ public struct TokenMetadata: Codable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(chainId, forKey: .chainId) try container.encodeIfPresent(contractAddress, forKey: .contractAddress) - try container.encode(tokenId, forKey: .tokenID) + try container.encode(tokenId, forKey: .tokenId) try container.encode(source, forKey: .source) try container.encode(name, forKey: .name) try container.encodeIfPresent(description, forKey: .description) diff --git a/Sources/OMSWallet/Models/Indexer/TokenMetadataAsset.swift b/Sources/OMSWallet/Models/Indexer/TokenMetadataAsset.swift index 32787cb..908cace 100644 --- a/Sources/OMSWallet/Models/Indexer/TokenMetadataAsset.swift +++ b/Sources/OMSWallet/Models/Indexer/TokenMetadataAsset.swift @@ -41,7 +41,6 @@ public struct TokenMetadataAsset: Codable, Sendable { case id case collectionId case tokenId - case tokenID case url case metadataField case name @@ -57,7 +56,6 @@ public struct TokenMetadataAsset: Codable, Sendable { self.id = try container.decodeIfPresent(Int64.self, forKey: .id) self.collectionId = try container.decodeIfPresent(Int64.self, forKey: .collectionId) self.tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) - ?? container.decodeIfPresent(String.self, forKey: .tokenID) self.url = try container.decodeIfPresent(String.self, forKey: .url) self.metadataField = try container.decodeIfPresent(String.self, forKey: .metadataField) self.name = try container.decodeIfPresent(String.self, forKey: .name) @@ -72,7 +70,7 @@ public struct TokenMetadataAsset: Codable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(id, forKey: .id) try container.encodeIfPresent(collectionId, forKey: .collectionId) - try container.encodeIfPresent(tokenId, forKey: .tokenID) + try container.encodeIfPresent(tokenId, forKey: .tokenId) try container.encodeIfPresent(url, forKey: .url) try container.encodeIfPresent(metadataField, forKey: .metadataField) try container.encodeIfPresent(name, forKey: .name) diff --git a/Sources/OMSWallet/Models/Indexer/Transaction.swift b/Sources/OMSWallet/Models/Indexer/Transaction.swift index 5c2bae5..6c7d778 100644 --- a/Sources/OMSWallet/Models/Indexer/Transaction.swift +++ b/Sources/OMSWallet/Models/Indexer/Transaction.swift @@ -47,7 +47,6 @@ public struct TransactionTransfer: Codable, Sendable { case from case to case tokenIds - case tokenIDs case amounts case logIndex case amountsUSD @@ -64,7 +63,6 @@ public struct TransactionTransfer: Codable, Sendable { self.from = try container.decode(String.self, forKey: .from) self.to = try container.decode(String.self, forKey: .to) self.tokenIds = try container.decodeIfPresent([String].self, forKey: .tokenIds) - ?? container.decodeIfPresent([String].self, forKey: .tokenIDs) self.amounts = try container.decode([String].self, forKey: .amounts) self.logIndex = try container.decode(Int.self, forKey: .logIndex) self.amountsUSD = try container.decodeIfPresent([String].self, forKey: .amountsUSD) @@ -80,7 +78,7 @@ public struct TransactionTransfer: Codable, Sendable { try container.encode(contractType, forKey: .contractType) try container.encode(from, forKey: .from) try container.encode(to, forKey: .to) - try container.encodeIfPresent(tokenIds, forKey: .tokenIDs) + try container.encodeIfPresent(tokenIds, forKey: .tokenIds) try container.encode(amounts, forKey: .amounts) try container.encode(logIndex, forKey: .logIndex) try container.encodeIfPresent(amountsUSD, forKey: .amountsUSD) @@ -122,7 +120,6 @@ public struct Transaction: Codable, Sendable { case blockNumber case blockHash case chainId - case metaTxnId case metaTxnID case transfers case timestamp @@ -134,8 +131,7 @@ public struct Transaction: Codable, Sendable { self.blockNumber = try container.decode(Int64.self, forKey: .blockNumber) self.blockHash = try container.decode(String.self, forKey: .blockHash) self.chainId = try container.decode(Int64.self, forKey: .chainId) - self.metaTxnId = try container.decodeIfPresent(String.self, forKey: .metaTxnId) - ?? container.decodeIfPresent(String.self, forKey: .metaTxnID) + self.metaTxnId = try container.decodeIfPresent(String.self, forKey: .metaTxnID) self.transfers = try container.decode([TransactionTransfer].self, forKey: .transfers) self.timestamp = try container.decode(String.self, forKey: .timestamp) } diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index 56aa317..b8f899a 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -453,14 +453,14 @@ func TestGetTronBalancesRejectsInvalidResponses(responseBody: String) async thro "contractType": "ERC721", "from": "0xwallet", "to": "0xrecipient", - "tokenIDs": ["7"], + "tokenIds": ["7"], "amounts": ["1"], "logIndex": 0, "tokenMetadata": { "7": { "chainId": 80002, "contractAddress": "0xcontract", - "tokenID": "7", + "tokenId": "7", "source": "metadata", "name": "Token 7", "attributes": [], @@ -572,7 +572,7 @@ func TestGetTronBalancesRejectsInvalidResponses(responseBody: String) async thro { "id": 1, "collectionId": 2, - "tokenID": "asset-token", + "tokenId": "asset-token", "url": "https://example.com/asset.png", "metadataField": "image", "name": "Asset", From 1b1b737270fbc1df0e7e6526e908347d8437d63d Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 9 Oct 2026 13:50:50 +0300 Subject: [PATCH 24/35] docs: align migration, README, and contributor docs with 0.4.0 behaviour - MIGRATION: blank walletAddress validation, invalid credential expiresAt, ContractTokenBalance tokenID key - error-contracts: listWallets also rejects malformed Ethereum addresses - README: guard EVM indexer examples on Ethereum wallets; placeholder Tron recipient - useWallet/createWallet doc comments describe the persisted wallet; regenerate API.md - AGENTS/TESTING/publishing/error-contracts: API.md is generated by scripts/generate-api.sh --- AGENTS.md | 10 ++++++---- API.md | 4 ++-- MIGRATION.md | 11 ++++++++--- README.md | 13 +++++++++---- Sources/OMSWallet/Clients/WalletAuth.swift | 4 ++-- TESTING.md | 6 +++--- docs/error-contracts.md | 7 ++++--- publishing.md | 2 +- 8 files changed, 35 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 83e7fea..d812aba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,10 +126,12 @@ requested later with `@claude review` in a PR comment. ## Documentation -Update `README.md` when user-facing setup or flow examples change. Update -`API.md` when public methods, parameters, models, or behavior change. Keep docs -aligned with the actual Swift names, labels, return types, and the `OMSWallet` -import name. Avoid adding method descriptions in source code. +Update `README.md` when user-facing setup or flow examples change. `API.md` is +generated: when public methods, parameters, models, or doc comments change, run +`scripts/generate-api.sh` (CI checks it with `--check`), regenerate the baseline +with `UPDATE_PUBLIC_API_BASELINE=1 scripts/check-public-api-does-not-expose-generated-waas.sh`, +and add a `MIGRATION.md` entry for breaking changes. Keep docs aligned with the +actual Swift names, labels, return types, and the `OMSWallet` import name. ## Demo App diff --git a/API.md b/API.md index d64fbe2..fbb1c94 100644 --- a/API.md +++ b/API.md @@ -126,7 +126,7 @@ Lists all wallets available to the authenticated credential. @discardableResult public func useWallet(walletId: String) async throws -> WalletActivationResult ``` -Activates an existing wallet by its WaaS wallet ID and persists its address and +Activates an existing wallet by its WaaS wallet ID and persists the wallet and signer metadata to the keychain. ### `WalletClient.createWallet(walletType:reference:)` @@ -136,7 +136,7 @@ signer metadata to the keychain. ``` Creates a new wallet of the specified type for the authenticated user and persists -its address and signer metadata to the keychain. +the wallet and signer metadata to the keychain. ### `WalletClient.importWallet(privateKey:reference:)` diff --git a/MIGRATION.md b/MIGRATION.md index bf78644..05d8421 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -98,7 +98,9 @@ method: "transfer" Ethereum wallets whose address is not `0x` followed by 40 hexadecimal digits are now rejected with `.invalidResponse`. Checksum casing is not enforced. Sign-in and wallet selection also reject, with `.invalidResponse`, a wallet whose network family or key origin the SDK does not recognize, instead -of activating a wallet whose saved session would be discarded on the next launch. +of activating a wallet whose saved session would be discarded on the next launch. An auth response +whose credential `expiresAt` is not a valid ISO-8601 timestamp now fails with `.invalidResponse` and +leaves the user signed out. Previously the session was activated without an expiry. ### Exhaustive switches @@ -119,6 +121,9 @@ now takes an optional `walletAddress`, and verification requests never send a wa - Without `walletAddress`, the active wallet's address is used. Signed out, the call throws `.sessionMissing`; if the active wallet belongs to another family (for example an Ethereum wallet and `isValidSolanaMessageSignature`), it throws `.validationError` before any request. +- An empty or whitespace-only `walletAddress` now throws `.validationError` ("walletAddress must not + be empty") before any request. It does not fall back to the active wallet. Previously the SDK sent + it to the wallet API, which rejected it with a request error. ```swift // 0.3.x @@ -280,8 +285,8 @@ declare members with these names on those types. ### Indexer model JSON keys Indexer models now encode and decode only the indexer gateway keys: `TransactionTransfer` uses -`tokenIds`, `TokenMetadata` and `TokenMetadataAsset` use `tokenId`, `Transaction` uses `metaTxnID`, -and `NativeTokenBalance` uses `balance`. +`tokenIds`, `TokenMetadata` and `TokenMetadataAsset` use `tokenId`, `ContractTokenBalance` uses +`tokenID`, `Transaction` uses `metaTxnID`, and `NativeTokenBalance` uses `balance`. ## 0.3.0 diff --git a/README.md b/README.md index a1ca58e..2376eac 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,8 @@ let typedDataValid = try await omsWallet.wallet.isValidTypedDataSignature( ### Query Balances ```swift -guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } +guard let activeWallet = omsWallet.wallet.activeWallet, activeWallet.type == .ethereum else { return } +let walletAddress = activeWallet.address let result = try await omsWallet.indexer.getBalances( GetBalancesParams( @@ -434,7 +435,8 @@ print(balance?.balance ?? "0") ### Query Transaction History ```swift -guard let walletAddress = omsWallet.wallet.activeWallet?.address else { return } +guard let activeWallet = omsWallet.wallet.activeWallet, activeWallet.type == .ethereum else { return } +let walletAddress = activeWallet.address let history = try await omsWallet.indexer.getTransactionHistory( GetTransactionHistoryParams( @@ -610,10 +612,13 @@ let isValid = try await omsWallet.wallet.isValidTronMessageSignature( signature: signature ) +// Replace with the Base58Check (`T…`) address that should receive the funds. +let recipient = "" + // TRX transfer. Values are in sun (1 TRX = 1,000,000 sun). let trxTransfer = try await omsWallet.wallet.sendTronTransaction( network: .nile, - to: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + to: recipient, value: try parseUnits(value: "1", decimals: 6) ) @@ -623,7 +628,7 @@ let trc20Transfer = try await omsWallet.wallet.callTronContract( contractAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", method: "transfer", args: [ - AbiArg(type: "address", value: .string("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")), + AbiArg(type: "address", value: .string(recipient)), AbiArg(type: "uint256", value: .string("1000000")), ] ) diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index 93d76dc..23293d0 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -737,7 +737,7 @@ extension WalletClient { } } - /// Activates an existing wallet by its WaaS wallet ID and persists its address and + /// Activates an existing wallet by its WaaS wallet ID and persists the wallet and /// signer metadata to the keychain. @discardableResult public func useWallet(walletId: String) async throws -> WalletActivationResult { @@ -754,7 +754,7 @@ extension WalletClient { } /// Creates a new wallet of the specified type for the authenticated user and persists - /// its address and signer metadata to the keychain. + /// the wallet and signer metadata to the keychain. /// /// Call this after `completeEmailAuth(code:walletSelection:walletType:)` returns /// `.walletSelection`, or when an authenticated session already exists. diff --git a/TESTING.md b/TESTING.md index 5a6c357..16c62d8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -27,9 +27,9 @@ error behavior. It serializes `OMSWalletError` into stable public fields: When a public SDK method gains, removes, or intentionally changes error behavior, update the test, [docs/error-contracts.md](docs/error-contracts.md), -`API.md`, and user-facing README examples together. Keep the tests -representative by covering each backend/transport/local failure family through -real public methods rather than duplicating the same assertion for every method. +the generated `API.md` (`scripts/generate-api.sh`), and user-facing README +examples together. Keep the tests representative by covering each +backend/transport/local failure family through real public methods rather than duplicating the same assertion for every method. ## Integration tests diff --git a/docs/error-contracts.md b/docs/error-contracts.md index b1ee674..de7d45c 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -38,8 +38,9 @@ should be present, and which tests own the contract. ## Maintenance Approach - Update this matrix, `Tests/OMSWalletTests/PublicErrorContractsTests.swift`, - `API.md`, and `README.md` together when a public SDK method gains, removes, or - intentionally changes an error contract. + the generated `API.md` (`scripts/generate-api.sh`), and `README.md` together + when a public SDK method gains, removes, or intentionally changes an error + contract. - Keep backend and upstream mapping tests representative rather than exhaustive per method. Cover each transport or response family through real public calls instead of duplicating the same assertions for every method. @@ -69,7 +70,7 @@ should be present, and which tests own the contract. | `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called without `walletAddress` | No active wallet, or the active wallet belongs to another chain family | `.sessionMissing` (or `.sessionExpired`), or `.validationError` | Sign in with a wallet of the method's family, or pass `walletAddress`; with an address no session is required. No remote request was made | Absent | `SignatureVerificationTests.swift` | | `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called with an empty or whitespace-only `walletAddress` | Blank address; it does not fall back to the active wallet | `.validationError` ("walletAddress must not be empty") | Pass a wallet address, or omit `walletAddress` to use the active wallet. No remote request was made | Absent | `SignatureVerificationTests.swift` | | Wallet signing, transactions, import, and owner access methods | SDK-local validation, wallet-family mismatch (checked against the active wallet's stored type), non-bare contract `method` name, or fee-selection failure | `.validationError` | Correct parameters, select the required wallet family, or fix local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift`, `WalletImportTests.swift`, `MockWalletTests.swift`, `TronWalletTests.swift` | -| Wallet auth, selection, creation, and import methods | Successful WaaS response with an Ethereum wallet address that is not `0x` plus 40 hex digits, an unreadable credential `expiresAt`, or a wallet to activate with an unknown network family or key origin | `.invalidResponse` | Treat the payload as unusable; the wallet is not activated | Absent | `ActiveWalletSessionTests.swift` | +| Wallet auth, selection, creation, import, and `listWallets` methods | Successful WaaS response with an Ethereum wallet address that is not `0x` plus 40 hex digits (any of these methods), an unreadable credential `expiresAt`, or a wallet to activate with an unknown network family or key origin (auth, selection, creation, and import only) | `.invalidResponse` | Treat the payload as unusable; the wallet is not activated | Absent | `ActiveWalletSessionTests.swift` | | `omsWallet.wallet.importWallet`, `importEncryptedWallet` | Address already imported (WaaS `AddressAlreadyImported`, code `7313`) | `.walletAddressAlreadyImported` (`OMS_WALLET_ADDRESS_ALREADY_IMPORTED`), `status == 409`, `retryable == false`; the active wallet is unchanged | Select the existing wallet or use a different key | Present | `PublicErrorContractsTests.swift` | | `omsWallet.wallet.getWalletImportRecipientKey`, `importWallet`, `importEncryptedWallet` | Recipient-key attestation is missing, stale, malformed, untrusted, or does not match the request/response | `.attestationVerificationFailed` with the wallet-import operation | Do not encrypt or submit key material; retry only after confirming the SDK's managed PCR0 trust policy and WaaS environment | Absent | `WalletImportTests.swift` | | `omsWallet.wallet.isValidMessageSignature`, `isValidSolanaMessageSignature`, `isValidTypedDataSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` | WaaS validation backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with the validation operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift` | diff --git a/publishing.md b/publishing.md index 6c7624a..68e931a 100644 --- a/publishing.md +++ b/publishing.md @@ -27,7 +27,7 @@ such as `0.2.0`, not `v0.2.0`, because the podspec source tag is - Update the CocoaPods install snippet in `README.md` to the same version. - Update the exact-version Swift Package Manager text (the literal text "version" followed by the backticked version) in `README.md` to the same version. This is mandatory: `scripts/verify.sh` fails without it. - Update the `Package.swift` example in `README.md` (`.upToNextMinor(from: "...")`) to the same version. `scripts/verify.sh` also fails without it. -- If public APIs, behavior, setup, or examples changed, update `API.md` and the relevant README sections in the same PR. +- If public APIs, behavior, setup, or examples changed, regenerate `API.md` with `scripts/generate-api.sh` and update the relevant README sections in the same PR. - If the release contains breaking changes, update `MIGRATION.md` with steps from the previous published version. From a419725e01ee50ff5e87075ee14c3100c4b6fce3 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Thu, 15 Oct 2026 16:22:45 +0300 Subject: [PATCH 25/35] feat(demo): add a mainnet switch to the Solana and Tron panels The Solana screens switch between Devnet and mainnet and the Tron screens between Nile and mainnet. Each network has its own USDC mint or USDT contract, explorer links and faucet links (testnets only), and mainnet shows that transfers move real funds. Switching clears the previous balances and transfer result, and a balance response for a network the screen has left is ignored. --- .../oms-wallet-demo/ContentView.swift | 330 ++++++++++++++---- 1 file changed, 263 insertions(+), 67 deletions(-) diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index 3fdf157..bfaa76a 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -1448,18 +1448,81 @@ private func sessionAuthLabel(_ auth: OMSWalletSessionAuth?) -> String { // MARK: - Tron Wallet Window -private let tronscanNileURL = "https://nile.tronscan.org/#" -private let nileFaucetURL = URL(string: "https://nileex.io/join/getJoinPage")! -private let nileUsdtContract = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" -private let nileUsdtDecimals = 6 +private let usdtDecimals = 6 private let trxDecimals = 6 -private func tronTransactionExplorerURL(_ txnHash: String) -> URL? { - URL(string: "\(tronscanNileURL)/transaction/\(txnHash)") +private struct TronNetworkConfig { + let network: TronNetwork + let label: String + let explorerURL: String + let usdtContract: String + let faucetURL: URL? } -private func tronAddressExplorerURL(_ address: String) -> URL? { - URL(string: "\(tronscanNileURL)/address/\(address)") +/// Networks the Tron panel can switch between. Nile is the default. +private enum TronDemoNetwork: String, CaseIterable, Identifiable { + case nile = "Nile testnet" + case mainnet = "Mainnet" + + var id: Self { self } + + var config: TronNetworkConfig { + switch self { + case .nile: + TronNetworkConfig( + network: .nile, + label: "Nile", + explorerURL: "https://nile.tronscan.org/#", + usdtContract: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + faucetURL: URL(string: "https://nileex.io/join/getJoinPage") + ) + case .mainnet: + TronNetworkConfig( + network: .mainnet, + label: "Mainnet", + explorerURL: "https://tronscan.org/#", + usdtContract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + faucetURL: nil + ) + } + } + + var sendTitle: String { + self == .mainnet ? "Send on Tron mainnet" : "Send on Tron Nile" + } +} + +private func tronTransactionExplorerURL(_ txnHash: String, config: TronNetworkConfig) -> URL? { + URL(string: "\(config.explorerURL)/transaction/\(txnHash)") +} + +private func tronAddressExplorerURL(_ address: String, config: TronNetworkConfig) -> URL? { + URL(string: "\(config.explorerURL)/address/\(address)") +} + +/// Network selector shared by the Solana and Tron wallet screens. +private struct DemoNetworkPicker: View +where Option.RawValue == String, Option.AllCases: RandomAccessCollection { + @Binding var selection: Option + let isMainnet: Bool + let isDisabled: Bool + + var body: some View { + FieldGroup(title: "Network") { + Picker("Network", selection: $selection) { + ForEach(Option.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(isDisabled) + + if isMainnet { + DesignText("Mainnet transfers move real funds.", variant: .caption) + } + } + } } private func formatTokenAmount(_ raw: String, decimals: Int) -> String { @@ -1469,19 +1532,23 @@ private func formatTokenAmount(_ raw: String, decimals: Int) -> String { struct TronWalletWindow: View { @EnvironmentObject private var vm: AppViewModel + @State private var network: TronDemoNetwork = .nile + @State private var isSendBusy: Bool = false + @State private var isSignBusy: Bool = false + var body: some View { TabView { - TronBalancesWindow() + TronBalancesWindow(network: $network, isBusy: isSendBusy || isSignBusy) .tabItem { Label("Wallet", systemImage: "wallet.pass") } - TronSendWindow() + TronSendWindow(network: $network, isSendBusy: $isSendBusy, isSignBusy: isSignBusy) .tabItem { Label("Send", systemImage: "arrow.up.circle") } - TronSignMessageWindow() + TronSignMessageWindow(isBusy: $isSignBusy) .tabItem { Label("Sign", systemImage: "signature") } @@ -1495,23 +1562,34 @@ struct TronWalletWindow: View { private struct TronBalancesWindow: View { @EnvironmentObject private var vm: AppViewModel + @Binding var network: TronDemoNetwork + let isBusy: Bool @State private var trxBalance: String = "—" @State private var usdtBalance: String = "—" @State private var balanceStatus: String? @State private var isFetchingBalance: Bool = false + @State private var balanceRequest: Int = 0 @State private var error: GenericAppError? + private var config: TronNetworkConfig { network.config } + var body: some View { ModalContainer( title: "Tron wallet", - subtitle: "Nile testnet. Tron wallets use native mode only. Transactions are often sponsored by each account's daily free bandwidth; once it is spent, the network fee is paid in TRX.", + subtitle: "Tron wallets use native mode only. Transactions are often sponsored by each account's daily free bandwidth; once it is spent, the network fee is paid in TRX.", showsCloseButton: false ) { + DemoNetworkPicker( + selection: $network, + isMainnet: network == .mainnet, + isDisabled: isBusy || vm.feeOptionSelectionRequest != nil + ) + if let address = vm.omsWallet.wallet.activeWallet?.address { FieldGroup(title: "Address") { CopyableResult(text: address) - if let url = tronAddressExplorerURL(address) { + if let url = tronAddressExplorerURL(address, config: config) { Link("View wallet on Tronscan", destination: url) .font(.caption.weight(.semibold)) } @@ -1521,8 +1599,8 @@ private struct TronBalancesWindow: View { DesignText(sessionAuthLabel(vm.omsWallet.wallet.session?.auth), variant: .caption) Panel { - tronBalanceRow(title: "Nile TRX balance", value: "\(trxBalance) TRX", faucetTitle: "Open TRX faucet") - tronBalanceRow(title: "Nile USDT balance", value: "\(usdtBalance) USDT", faucetTitle: "Open USDT faucet") + tronBalanceRow(title: "\(config.label) TRX balance", value: "\(trxBalance) TRX", faucetTitle: "Open TRX faucet") + tronBalanceRow(title: "\(config.label) USDT balance", value: "\(usdtBalance) USDT", faucetTitle: "Open USDT faucet") if let balanceStatus { DesignText(balanceStatus, variant: .error) @@ -1545,7 +1623,12 @@ private struct TronBalancesWindow: View { .buttonStyle(DesignButtonStyle(variant: .secondary)) } .genericErrorWindow(error: $error) - .task { + .onChange(of: network) { _ in + trxBalance = "—" + usdtBalance = "—" + balanceStatus = nil + } + .task(id: network) { await refreshBalances() } } @@ -1554,8 +1637,10 @@ private struct TronBalancesWindow: View { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { DesignText(title, variant: .caption) - Link(faucetTitle, destination: nileFaucetURL) - .font(.caption.weight(.semibold)) + if let faucetURL = config.faucetURL { + Link(faucetTitle, destination: faucetURL) + .font(.caption.weight(.semibold)) + } } Spacer() @@ -1575,19 +1660,26 @@ private struct TronBalancesWindow: View { private func refreshBalances() async { guard let address = vm.omsWallet.wallet.activeWallet?.address else { return } + let config = config + balanceRequest += 1 + let request = balanceRequest isFetchingBalance = true balanceStatus = nil - defer { isFetchingBalance = false } + defer { + if request == balanceRequest { isFetchingBalance = false } + } do { let result = try await vm.omsWallet.indexer.getTronBalances( GetTronBalancesParams( walletAddress: address, - networks: [.nile], - contractAddresses: [nileUsdtContract] + networks: [config.network], + contractAddresses: [config.usdtContract] ) ) - if let networkError = result.errors.first(where: { $0.network == .nile }) { + // Ignore a response for a network the screen has already moved away from. + guard request == balanceRequest else { return } + if let networkError = result.errors.first(where: { $0.network == config.network }) { trxBalance = "—" usdtBalance = "—" balanceStatus = networkError.reason @@ -1600,15 +1692,16 @@ private struct TronBalancesWindow: View { switch balance { case .native(let native): trxRaw = native.balance - case .fungibleToken(let token) where token.contractAddress == nileUsdtContract: + case .fungibleToken(let token) where token.contractAddress == config.usdtContract: usdtRaw = token.balance case .fungibleToken: break } } trxBalance = formatTokenAmount(trxRaw, decimals: trxDecimals) - usdtBalance = formatTokenAmount(usdtRaw, decimals: nileUsdtDecimals) + usdtBalance = formatTokenAmount(usdtRaw, decimals: usdtDecimals) } catch { + guard request == balanceRequest else { return } trxBalance = "—" usdtBalance = "—" self.error = GenericAppError(error) @@ -1626,6 +1719,9 @@ private enum TronTransferAsset: String, CaseIterable, Identifiable { private struct TronSendWindow: View { @EnvironmentObject private var vm: AppViewModel + @Binding var network: TronDemoNetwork + @Binding var isSendBusy: Bool + let isSignBusy: Bool @State private var asset: TronTransferAsset = .trx @State private var recipientText: String = "" @@ -1639,10 +1735,16 @@ private struct TronSendWindow: View { var body: some View { ModalContainer( - title: "Send on Tron Nile", + title: network.sendTitle, subtitle: "Send TRX or TRC-20 tokens. TRX transfers use sendTronTransaction; token transfers call the contract's transfer method.", showsCloseButton: false ) { + DemoNetworkPicker( + selection: $network, + isMainnet: network == .mainnet, + isDisabled: isSending || isSignBusy || vm.feeOptionSelectionRequest != nil + ) + FieldGroup(title: "Asset") { Picker("Asset", selection: $asset) { ForEach(TronTransferAsset.allCases) { asset in @@ -1653,7 +1755,7 @@ private struct TronSendWindow: View { .labelsHidden() if asset == .usdt { - DesignText("USDT contract address: \(nileUsdtContract)", variant: .caption) + DesignText("USDT contract address: \(network.config.usdtContract)", variant: .caption) .textSelection(.enabled) } } @@ -1697,7 +1799,7 @@ private struct TronSendWindow: View { Button { Task { await sendTransfer() } } label: { - label(for: "Send on Tron Nile", systemImage: "paperplane", loading: isSending) + label(for: network.sendTitle, systemImage: "paperplane", loading: isSending) } .buttonStyle(DesignButtonStyle(variant: .primary)) .disabled(!canSend || isSending) @@ -1710,7 +1812,7 @@ private struct TronSendWindow: View { TransactionResultPanel(result: result) if let txnHash = result.txnHash, !txnHash.isEmpty, - let url = tronTransactionExplorerURL(txnHash) { + let url = tronTransactionExplorerURL(txnHash, config: network.config) { Link("View on Tronscan", destination: url) .font(.caption.weight(.semibold)) } @@ -1721,6 +1823,11 @@ private struct TronSendWindow: View { FeeOptionSelectionWindow(request: request) .environmentObject(vm) } + .onChange(of: network) { _ in + result = nil + transferStatus = nil + } + .onChange(of: isSending) { isSendBusy = $0 } } private var amountTitle: String { @@ -1760,13 +1867,14 @@ private struct TronSendWindow: View { try await vm.selectFeeOption(options) } let statusPolling = TransactionStatusPollingOptions(timeoutMs: 120_000) + let config = network.config do { let transaction: SendTransactionResponse switch asset { case .trx: transaction = try await vm.omsWallet.wallet.sendTronTransaction( - network: .nile, + network: config.network, to: recipient, value: try parseUnits(value: amount, decimals: trxDecimals), selectFeeOption: selectFeeOption, @@ -1774,12 +1882,12 @@ private struct TronSendWindow: View { ) case .usdt, .custom: let contract = asset == .usdt - ? nileUsdtContract + ? config.usdtContract : tokenContractText.trimmingCharacters(in: .whitespacesAndNewlines) - let decimals = asset == .usdt ? nileUsdtDecimals : (tokenDecimals ?? 0) + let decimals = asset == .usdt ? usdtDecimals : (tokenDecimals ?? 0) // TRC-20 transfers are contract calls; the wallet service ABI-encodes the arguments. transaction = try await vm.omsWallet.wallet.callTronContract( - network: .nile, + network: config.network, contractAddress: contract, method: "transfer", args: [ @@ -1805,6 +1913,7 @@ private struct TronSendWindow: View { private struct TronSignMessageWindow: View { @EnvironmentObject private var vm: AppViewModel + @Binding var isBusy: Bool @State private var messageText: String = "Sign in to OMS Wallet" @State private var signature: String = "" @@ -1840,6 +1949,7 @@ private struct TronSignMessageWindow: View { } } .genericErrorWindow(error: $error) + .onChange(of: isSigning) { isBusy = $0 } } private func signAndVerify() async { @@ -1870,18 +1980,57 @@ private struct TronSignMessageWindow: View { // MARK: - Solana Wallet Window private let solanaExplorerURL = "https://explorer.solana.com" -private let solFaucetURL = URL(string: "https://faucet.solana.com/")! -private let devnetUsdcFaucetURL = URL(string: "https://faucet.circle.com/")! -private let devnetUsdcMint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" -private let devnetUsdcDecimals = 6 +private let usdcDecimals = 6 private let solDecimals = 9 -private func solanaTransactionExplorerURL(_ signature: String) -> URL? { - URL(string: "\(solanaExplorerURL)/tx/\(signature)?cluster=devnet") +private struct SolanaNetworkConfig { + let network: SolanaNetwork + let label: String + let usdcMint: String + /// Solana Explorer `cluster` query parameter; mainnet is the explorer default. + let explorerCluster: String? + let solFaucetURL: URL? + let usdcFaucetURL: URL? +} + +/// Networks the Solana panel can switch between. Devnet is the default. +private enum SolanaDemoNetwork: String, CaseIterable, Identifiable { + case devnet = "Devnet" + case mainnet = "Mainnet" + + var id: Self { self } + + var config: SolanaNetworkConfig { + switch self { + case .devnet: + SolanaNetworkConfig( + network: .devnet, + label: "Devnet", + usdcMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + explorerCluster: "devnet", + solFaucetURL: URL(string: "https://faucet.solana.com/"), + usdcFaucetURL: URL(string: "https://faucet.circle.com/") + ) + case .mainnet: + SolanaNetworkConfig( + network: .mainnet, + label: "Mainnet", + usdcMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + explorerCluster: nil, + solFaucetURL: nil, + usdcFaucetURL: nil + ) + } + } + + var sendTitle: String { + self == .mainnet ? "Send on Solana mainnet" : "Send on Solana Devnet" + } } -private func solanaAddressExplorerURL(_ address: String) -> URL? { - URL(string: "\(solanaExplorerURL)/address/\(address)?cluster=devnet") +private func solanaExplorerURL(_ path: String, config: SolanaNetworkConfig) -> URL? { + let cluster = config.explorerCluster.map { "?cluster=\($0)" } ?? "" + return URL(string: "\(solanaExplorerURL)/\(path)\(cluster)") } private struct SolanaDemoError: LocalizedError { @@ -1907,19 +2056,23 @@ private func solanaBaseUnits(_ amount: String, decimals: Int, label: String) thr struct SolanaWalletWindow: View { @EnvironmentObject private var vm: AppViewModel + @State private var network: SolanaDemoNetwork = .devnet + @State private var isSendBusy: Bool = false + @State private var isSignBusy: Bool = false + var body: some View { TabView { - SolanaBalancesWindow() + SolanaBalancesWindow(network: $network, isBusy: isSendBusy || isSignBusy) .tabItem { Label("Wallet", systemImage: "wallet.pass") } - SolanaSendWindow() + SolanaSendWindow(network: $network, isSendBusy: $isSendBusy, isSignBusy: isSignBusy) .tabItem { Label("Send", systemImage: "arrow.up.circle") } - SolanaSignMessageWindow() + SolanaSignMessageWindow(isBusy: $isSignBusy) .tabItem { Label("Sign", systemImage: "signature") } @@ -1933,23 +2086,34 @@ struct SolanaWalletWindow: View { private struct SolanaBalancesWindow: View { @EnvironmentObject private var vm: AppViewModel + @Binding var network: SolanaDemoNetwork + let isBusy: Bool @State private var solBalance: String = "—" @State private var usdcBalance: String = "—" @State private var balanceStatus: String? @State private var isFetchingBalance: Bool = false + @State private var balanceRequest: Int = 0 @State private var error: GenericAppError? + private var config: SolanaNetworkConfig { network.config } + var body: some View { ModalContainer( title: "Solana wallet", - subtitle: "Devnet. Transfers are relayed, so the network fee is sponsored. Sending an SPL token to a wallet without a token account asks you to choose how to pay its rent.", + subtitle: "Transfers are relayed, so the network fee is sponsored. Sending an SPL token to a wallet without a token account asks you to choose how to pay its rent.", showsCloseButton: false ) { + DemoNetworkPicker( + selection: $network, + isMainnet: network == .mainnet, + isDisabled: isBusy || vm.feeOptionSelectionRequest != nil + ) + if let address = vm.omsWallet.wallet.activeWallet?.address { FieldGroup(title: "Address") { CopyableResult(text: address) - if let url = solanaAddressExplorerURL(address) { + if let url = solanaExplorerURL("address/\(address)", config: config) { Link("View wallet on Solana Explorer", destination: url) .font(.caption.weight(.semibold)) } @@ -1960,16 +2124,16 @@ private struct SolanaBalancesWindow: View { Panel { solanaBalanceRow( - title: "Devnet SOL balance", + title: "\(config.label) SOL balance", value: "\(solBalance) SOL", faucetTitle: "Open SOL faucet", - faucetURL: solFaucetURL + faucetURL: config.solFaucetURL ) solanaBalanceRow( - title: "Devnet USDC balance", + title: "\(config.label) USDC balance", value: "\(usdcBalance) USDC", faucetTitle: "Open USDC faucet", - faucetURL: devnetUsdcFaucetURL + faucetURL: config.usdcFaucetURL ) if let balanceStatus { @@ -1993,17 +2157,24 @@ private struct SolanaBalancesWindow: View { .buttonStyle(DesignButtonStyle(variant: .secondary)) } .genericErrorWindow(error: $error) - .task { + .onChange(of: network) { _ in + solBalance = "—" + usdcBalance = "—" + balanceStatus = nil + } + .task(id: network) { await refreshBalances() } } - private func solanaBalanceRow(title: String, value: String, faucetTitle: String, faucetURL: URL) -> some View { + private func solanaBalanceRow(title: String, value: String, faucetTitle: String, faucetURL: URL?) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { DesignText(title, variant: .caption) - Link(faucetTitle, destination: faucetURL) - .font(.caption.weight(.semibold)) + if let faucetURL { + Link(faucetTitle, destination: faucetURL) + .font(.caption.weight(.semibold)) + } } Spacer() @@ -2023,19 +2194,26 @@ private struct SolanaBalancesWindow: View { private func refreshBalances() async { guard let address = vm.omsWallet.wallet.activeWallet?.address else { return } + let config = config + balanceRequest += 1 + let request = balanceRequest isFetchingBalance = true balanceStatus = nil - defer { isFetchingBalance = false } + defer { + if request == balanceRequest { isFetchingBalance = false } + } do { let result = try await vm.omsWallet.indexer.getSolanaBalances( GetSolanaBalancesParams( walletAddress: address, - networks: [.devnet], - mintAddresses: [devnetUsdcMint] + networks: [config.network], + mintAddresses: [config.usdcMint] ) ) - if let networkError = result.errors.first(where: { $0.network == .devnet }) { + // Ignore a response for a network the screen has already moved away from. + guard request == balanceRequest else { return } + if let networkError = result.errors.first(where: { $0.network == config.network }) { solBalance = "—" usdcBalance = "—" balanceStatus = networkError.reason @@ -2044,19 +2222,20 @@ private struct SolanaBalancesWindow: View { var solRaw = "0" var usdcRaw = "0" - for balance in result.balances where balance.network == .devnet { + for balance in result.balances where balance.network == config.network { switch balance { case .native: solRaw = balance.balance - case .fungibleToken(let token) where token.mintAddress == devnetUsdcMint: + case .fungibleToken(let token) where token.mintAddress == config.usdcMint: usdcRaw = balance.balance case .fungibleToken: break } } solBalance = formatTokenAmount(solRaw, decimals: solDecimals) - usdcBalance = formatTokenAmount(usdcRaw, decimals: devnetUsdcDecimals) + usdcBalance = formatTokenAmount(usdcRaw, decimals: usdcDecimals) } catch { + guard request == balanceRequest else { return } solBalance = "—" usdcBalance = "—" self.error = GenericAppError(error) @@ -2074,6 +2253,9 @@ private enum SolanaTransferAsset: String, CaseIterable, Identifiable { private struct SolanaSendWindow: View { @EnvironmentObject private var vm: AppViewModel + @Binding var network: SolanaDemoNetwork + @Binding var isSendBusy: Bool + let isSignBusy: Bool @State private var asset: SolanaTransferAsset = .sol @State private var recipientText: String = "" @@ -2087,10 +2269,16 @@ private struct SolanaSendWindow: View { var body: some View { ModalContainer( - title: "Send on Solana Devnet", + title: network.sendTitle, subtitle: "Send SOL or an SPL token with sendSolanaTransfer. Transfers are relayed; the wallet service creates the recipient's token account when needed.", showsCloseButton: false ) { + DemoNetworkPicker( + selection: $network, + isMainnet: network == .mainnet, + isDisabled: isSending || isSignBusy || vm.feeOptionSelectionRequest != nil + ) + FieldGroup(title: "Asset") { Picker("Asset", selection: $asset) { ForEach(SolanaTransferAsset.allCases) { asset in @@ -2102,7 +2290,7 @@ private struct SolanaSendWindow: View { .disabled(isSending) if asset == .usdc { - DesignText("USDC mint address: \(devnetUsdcMint)", variant: .caption) + DesignText("USDC mint address: \(network.config.usdcMint)", variant: .caption) .textSelection(.enabled) } } @@ -2150,7 +2338,7 @@ private struct SolanaSendWindow: View { Button { Task { await sendTransfer() } } label: { - label(for: "Send on Solana Devnet", systemImage: "paperplane", loading: isSending) + label(for: network.sendTitle, systemImage: "paperplane", loading: isSending) } .buttonStyle(DesignButtonStyle(variant: .primary)) .disabled(!canSend || isSending) @@ -2163,7 +2351,7 @@ private struct SolanaSendWindow: View { TransactionResultPanel(result: result) if let signature = result.txnHash, !signature.isEmpty, - let url = solanaTransactionExplorerURL(signature) { + let url = solanaExplorerURL("tx/\(signature)", config: network.config) { Link("View on Solana Explorer", destination: url) .font(.caption.weight(.semibold)) } @@ -2177,6 +2365,11 @@ private struct SolanaSendWindow: View { .onChange(of: asset) { asset in amountText = asset == .sol ? "0.001" : "1" } + .onChange(of: network) { _ in + result = nil + transferStatus = nil + } + .onChange(of: isSending) { isSendBusy = $0 } } private var amountTitle: String { @@ -2215,6 +2408,7 @@ private struct SolanaSendWindow: View { let selectFeeOption = FeeOptionSelector.custom { options in try await vm.selectFeeOption(options) } + let config = network.config do { let transferAsset: String @@ -2224,15 +2418,15 @@ private struct SolanaSendWindow: View { transferAsset = "SOL" baseUnits = try solanaBaseUnits(amount, decimals: solDecimals, label: "SOL") case .usdc: - transferAsset = devnetUsdcMint - baseUnits = try solanaBaseUnits(amount, decimals: devnetUsdcDecimals, label: "USDC") + transferAsset = config.usdcMint + baseUnits = try solanaBaseUnits(amount, decimals: usdcDecimals, label: "USDC") case .custom: transferAsset = mintText.trimmingCharacters(in: .whitespacesAndNewlines) baseUnits = try solanaBaseUnits(amount, decimals: tokenDecimals ?? 0, label: "token") } let transaction = try await vm.omsWallet.wallet.sendSolanaTransfer( - network: .devnet, + network: config.network, asset: transferAsset, to: recipient, amount: baseUnits, @@ -2253,6 +2447,7 @@ private struct SolanaSendWindow: View { private struct SolanaSignMessageWindow: View { @EnvironmentObject private var vm: AppViewModel + @Binding var isBusy: Bool @State private var messageText: String = "Sign in to OMS Wallet" @State private var signature: String = "" @@ -2288,6 +2483,7 @@ private struct SolanaSignMessageWindow: View { } } .genericErrorWindow(error: $error) + .onChange(of: isSigning) { isBusy = $0 } } private func signAndVerify() async { From d8e2e7aca013439e22358c2dd19b65d57ea8bcba Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 16 Oct 2026 18:09:07 +0300 Subject: [PATCH 26/35] fix: keep a newer session when a stale auth attempt fails A sign-in attempt that failed after a newer sign-in completed (for example an email-auth response with an unreadable credential expiresAt, or a later wallet-listing failure) signed out unconditionally and cleared the newer session, its signer key and its stored record. Failed attempts now clear the session only while it is still the one the attempt started from. --- Sources/OMSWallet/Clients/WalletAuth.swift | 21 +++++++-- .../ActiveWalletSessionTests.swift | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index 23293d0..eab765a 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -567,7 +567,7 @@ extension WalletClient { guard OMSWalletSession.parseDate(response.credential.expiresAt) != nil else { // The redirect callback's caller clears its own session on failure. if oidcRedirectAuthOwnership == nil { - try? signOut() + clearSessionIfStillCurrent(requiredSessionRevision) } throw OMSWalletError(code: .invalidResponse, message: "Auth response has an invalid credential expiresAt") } @@ -585,7 +585,10 @@ extension WalletClient { } } - let wallets = try await signOutOnFailure(unlessOwnedBy: oidcRedirectAuthOwnership) { + let wallets = try await signOutOnFailure( + unlessOwnedBy: oidcRedirectAuthOwnership, + requiredSessionRevision: requiredSessionRevision + ) { try await walletsFromAuthResponse(response) } try withOptionalOIDCRedirectAuthOwnership(oidcRedirectAuthOwnership) { @@ -609,7 +612,10 @@ extension WalletClient { ) } - let activated = try await signOutOnFailure(unlessOwnedBy: oidcRedirectAuthOwnership) { + let activated = try await signOutOnFailure( + unlessOwnedBy: oidcRedirectAuthOwnership, + requiredSessionRevision: requiredSessionRevision + ) { if let selectedWallet = candidateWallets.first { return try await useWallet( walletId: selectedWallet.id, @@ -722,6 +728,7 @@ extension WalletClient { /// non-`nil`), whose caller clears the session itself. private func signOutOnFailure( unlessOwnedBy ownership: PendingOIDCRedirectAuth?, + requiredSessionRevision: UInt64, _ operation: () async throws -> T ) async throws -> T { guard ownership == nil else { @@ -732,11 +739,17 @@ extension WalletClient { } catch let error as CancellationError { throw error } catch { - try? signOut() + clearSessionIfStillCurrent(requiredSessionRevision) throw error } } + /// Clears the session after a failed auth attempt, unless a newer sign-in or sign-out has + /// already replaced the session this attempt started from. + private func clearSessionIfStillCurrent(_ requiredSessionRevision: UInt64) { + _ = try? clearSession(clearOidcRedirectAuth: true, requiredSessionRevision: requiredSessionRevision) + } + /// Activates an existing wallet by its WaaS wallet ID and persists the wallet and /// signer metadata to the keychain. @discardableResult diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift index aa91e7d..b01a605 100644 --- a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -184,6 +184,53 @@ func TestDiscardsStoredSessionsWithInvalidWallets(walletJSON: String) throws { #expect(!fixture.signer.hasStoredCredential) } +/// Returns a fixed response, running `beforeResponse` while the request is in flight. +private final class InterleavingTransport: WebRPCTransport, @unchecked Sendable { + let response: Data + let beforeResponse: () async throws -> Void + + init(response: Data, beforeResponse: @escaping () async throws -> Void) { + self.response = response + self.beforeResponse = beforeResponse + } + + func post(baseURL: String, path: String, body: Data, headers: [String: String]) async throws -> WebRPCHTTPResponse { + try await beforeResponse() + return WebRPCHTTPResponse(statusCode: 200, body: response) + } +} + +@Test func TestStaleInvalidAuthResponseKeepsNewerSession() async throws { + let fixture = makeMockWalletClient() + let newerWallet = testWallet(id: "newer-wallet", address: "0x1111111111111111111111111111111111111111") + try fixture.transport.enqueue( + CommitVerifierResponse(verifier: "newer-verifier", challenge: "newer-challenge"), + for: WaasAPI.CommitVerifier.urlPath + ) + try fixture.transport.enqueue(completeAuthResponse(wallets: [newerWallet]), for: WaasAPI.CompleteAuth.urlPath) + try fixture.transport.enqueue(UseWalletResponse(wallet: newerWallet), for: WaasAPI.UseWallet.urlPath) + let staleResponse = completeAuthResponse( + wallets: [], + credential: WaasCredentialInfo(credentialId: "0xstale", type: .direct, expiresAt: "soon", isCaller: true) + ) + // While the first completion is in flight, a second sign-in starts and completes. + fixture.client.signedClient = WaasClient( + baseURL: fixture.environment.walletApiUrl, + transport: InterleavingTransport(response: try WebRPCJSON.makeEncoder().encode(staleResponse)) { + try await fixture.client.startEmailAuth(email: "newer@example.com") + _ = try await fixture.client.completeEmailAuth(code: "654321") + } + ) + + do { + _ = try await fixture.client.completeEmailAuth(code: "123456") + Issue.record("Expected the stale auth response to fail") + } catch {} + #expect(fixture.client.activeWallet?.id == "newer-wallet") + #expect(fixture.signer.hasStoredCredential) + #expect(try fixture.storedCredentials()?.wallet.id == "newer-wallet") +} + @Test(arguments: [ WaasWallet( id: "wallet-1", From 74d08a22c223677286216dca43d46ed072299c20 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 16 Oct 2026 19:00:23 +0300 Subject: [PATCH 27/35] test: cover delayed session expiry, OIDC pending-flow consumption, sponsored selector - Drive the delayed session-expiry task with a near-future expiry and a movable clock, so the timer itself has to fire (previously only the already-expired branch ran). - Assert ignored OIDC callbacks (unrelated redirect, invalid state) leave the pending flow unconsumed and that the real callback still completes sign-in. - Record sponsored EVM fee-selector invocations instead of asserting inside the closure. --- Tests/OMSWalletTests/MockWalletTests.swift | 75 ++++++++++++++++++---- Tests/OMSWalletTests/TronWalletTests.swift | 2 +- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 2f89ff4..94302f2 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -506,9 +506,12 @@ private func waitForSessionExpiredEvent( return event() } -@Test @MainActor func TestWalletSessionExpiryTimerReplaysObserver() async throws { +@Test @MainActor func TestWalletSessionExpiryTimerFiresAfterDelayAndNotifiesObserver() async throws { + // The credential expires at 1_767_225_600. Start shortly before it so the client schedules the + // delayed expiry task instead of taking the already-expired branch. + let now = LockedBox(Date(timeIntervalSince1970: 1_767_225_599.8)) let fixture = makeMockWalletClient( - currentDate: { Date(timeIntervalSince1970: 1_767_225_601) } + currentDate: { now.withValue { $0 } } ) let wallet = testWallet(id: "wallet-expired-timer", address: "0x2222222222222222222222222222222222222222") let credential = WaasCredentialInfo( @@ -526,19 +529,26 @@ private func waitForSessionExpiredEvent( for: WaasAPI.UseWallet.urlPath ) - _ = try await fixture.client.completeEmailAuth(code: "123456") - await Task.yield() - await Task.yield() - var expiredEvent: OMSWalletSessionExpiredEvent? + var expiredEvents: [OMSWalletSessionExpiredEvent] = [] let observation = fixture.client.addSessionExpiredObserver { event in - expiredEvent = event + expiredEvents.append(event) } defer { observation.cancel() } - let replayedEvent = try await waitForSessionExpiredEvent { expiredEvent } - #expect(replayedEvent?.wallet?.address == wallet.address) - #expect(isEmailAuth(replayedEvent?.session.auth)) + _ = try await fixture.client.completeEmailAuth(code: "123456") + await Task.yield() + #expect(fixture.client.session?.expiresAt == "2026-01-01T00:00:00Z") + #expect(fixture.client.activeWallet?.address == wallet.address) + #expect(expiredEvents.isEmpty) + + now.withValue { $0 = Date(timeIntervalSince1970: 1_767_225_601) } + let expiredEvent = try await waitForSessionExpiredEvent { expiredEvents.first } + + #expect(expiredEvents.count == 1) + #expect(expiredEvent?.wallet?.address == wallet.address) + #expect(isEmailAuth(expiredEvent?.session.auth)) #expect(fixture.client.session == nil) + #expect(fixture.client.activeWallet == nil) #expect(try fixture.storedCredentials()?.wallet.id == wallet.id) } @@ -1738,6 +1748,39 @@ private func waitForSessionExpiredEvent( #expect(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 0) } +/// Follows an ignored callback with the real one and checks it still completes sign-in, which +/// fails if the ignored callback consumed or cleared the pending redirect. +private func expectRealOidcCallbackStillCompletes( + fixture: MockWalletClientFixture, + started: StartOIDCRedirectAuthResult +) async throws { + let selectedWallet = testWallet(id: "wallet-oidc", address: "0x0dc0000000000000000000000000000000000000") + try fixture.transport.enqueue( + completeAuthResponse( + identity: Identity(type: .oidc, iss: "https://issuer.example", sub: "oidc-sub-123"), + wallets: [selectedWallet] + ), + for: WaasAPI.CompleteAuth.urlPath + ) + try fixture.transport.enqueue( + UseWalletResponse(wallet: selectedWallet), + for: WaasAPI.UseWallet.urlPath + ) + + let result = try await fixture.client.handleOIDCRedirectCallback( + "omsclientswiftdemo://auth/callback?code=auth-code&state=\(try oidcState(started))" + ) + + guard case .completed(.walletSelected(let wallet, _, _)) = result else { + Issue.record("Expected the real OIDC callback to complete, got \(result)") + return + } + #expect(wallet.id == selectedWallet.id) + #expect(fixture.client.activeWallet?.id == selectedWallet.id) + #expect(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 1) + #expect(fixture.oidcRedirectAuthStore.pending == nil) +} + @Test func TestWalletHandleOidcRedirectCallbackIgnoresUnrelatedCallbackWithoutClearingPendingAuth() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) try fixture.transport.enqueue( @@ -1766,7 +1809,10 @@ private func waitForSessionExpiredEvent( return } #expect(fixture.oidcRedirectAuthStore.pending?.verifier == "oidc-verifier-123") + #expect(fixture.oidcRedirectAuthStore.pending?.isConsumed == false) #expect(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 0) + + try await expectRealOidcCallbackStillCompletes(fixture: fixture, started: started) } @Test func TestWalletHandleOidcRedirectCallbackIgnoresInvalidStateWithoutClearingPendingAuth() async throws { @@ -1779,7 +1825,7 @@ private func waitForSessionExpiredEvent( ), for: WaasAPI.CommitVerifier.urlPath ) - _ = try await fixture.client.startOIDCRedirectAuth( + let started = try await fixture.client.startOIDCRedirectAuth( provider: CustomOIDCProviderConfiguration( issuer: "https://issuer.example", clientID: "client-123", @@ -1797,7 +1843,10 @@ private func waitForSessionExpiredEvent( return } #expect(fixture.oidcRedirectAuthStore.pending?.verifier == "oidc-verifier-123") + #expect(fixture.oidcRedirectAuthStore.pending?.isConsumed == false) #expect(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 0) + + try await expectRealOidcCallbackStillCompletes(fixture: fixture, started: started) } @Test func TestWalletHandleOIDCRedirectCallbackReturnsNoPendingAuth() async throws { @@ -2749,11 +2798,12 @@ private func waitForSessionExpiredEvent( for: WaasAPI.TransactionStatusMethod.urlPath ) + let receivedOptionCounts = LockedBox<[Int]>([]) let txResult = try await fixture.client.sendTransaction( network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .custom { feeOptions in - #expect(feeOptions.isEmpty) + receivedOptionCounts.withValue { $0.append(feeOptions.count) } return nil } ) @@ -2762,6 +2812,7 @@ private func waitForSessionExpiredEvent( for: WaasAPI.Execute.urlPath ) + #expect(receivedOptionCounts.withValue { $0 } == [0]) #expect(txResult.txnId == "txn-1") #expect(txResult.status == .executed) #expect(txResult.txnHash == nil) diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift index 347bce8..1869eb8 100644 --- a/Tests/OMSWalletTests/TronWalletTests.swift +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -8,7 +8,7 @@ private let tronWalletHex = "0x8840e6c55b9ada326d211d818c34a994aeced808" private let tronRecipient = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" private let tronUsdt = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" -private final class LockedBox: @unchecked Sendable { +final class LockedBox: @unchecked Sendable { private let lock = NSLock() private var value: Value From c5eff38d3d2726f2403ed772bdfe3db3eef19dc8 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 16 Oct 2026 19:01:24 +0300 Subject: [PATCH 28/35] test: tighten weak tests Pin the OIDC ID-token parity vector to a literal token and handle hash computed outside the SDK, instead of recomputing the expected hash with a helper that mirrors the implementation. --- Tests/OMSWalletTests/RequestsTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/OMSWalletTests/RequestsTests.swift b/Tests/OMSWalletTests/RequestsTests.swift index 855eef2..c5efa66 100644 --- a/Tests/OMSWalletTests/RequestsTests.swift +++ b/Tests/OMSWalletTests/RequestsTests.swift @@ -26,10 +26,16 @@ import Testing } @Test func TestOidcIdTokenPayloadHelpersMatchParityVector() throws { - let idToken = try fakeOidcIdToken(exp: 1_910_000_100) + // Literal vector: header {"alg":"none","typ":"JWT"}, payload {"aud":"demo-web-client-id", + // "email":"user@example.com","exp":1910000100,"iss":"https://accounts.google.com", + // "sub":"google-sub-123"}. The handle is base64url(SHA-256(token)), computed outside the SDK. + let idToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" + + ".eyJhdWQiOiJkZW1vLXdlYi1jbGllbnQtaWQiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJleHAiOjE5MTAw" + + "MDAxMDAsImlzcyI6Imh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbSIsInN1YiI6Imdvb2dsZS1zdWItMTIzIn0" + + ".test-signature" #expect(try OidcIdToken.expiresAtEpochSeconds(idToken) == 1_910_000_100) - #expect(OidcIdToken.handleHash(idToken) == expectedOidcHandleHash(idToken)) + #expect(OidcIdToken.handleHash(idToken) == "eFZOodqrVYyRrQXoMyRVFyFSAj4PsRNgR8ktA8AaeUI") } @Test func TestOidcIdTokenRequiresExpirationClaim() throws { From 81742222b6856de7d343f269647e003448bb8bf4 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 16 Oct 2026 19:09:59 +0300 Subject: [PATCH 29/35] test: remove duplicate and low-value tests Delete tests whose every assertion is held elsewhere, moving any unique assertion into the partner first: - signed-out nil state and custom-provider defaults move into the auth and redirect-start tests - the Tron signature test's isValid=false case becomes a true/false column of the parameterized verification matrix - tautological error-field, enum raw-value (pinned by API.md), and HPKE length tests go - session-lifetime validation becomes one test over four entry points and both bounds - fee-selector parsing, skip-polling, timed-out statuses, ID-token expiry, and key-import validation become parameterized tables - listAccess aggregation, indexer strictness rows, indexer HTTP error cases, and stored-wallet validation cases fold into their existing owners --- .../ActiveWalletSessionTests.swift | 16 +- Tests/OMSWalletTests/IndexerTests.swift | 93 +--- Tests/OMSWalletTests/MockWalletTests.swift | 442 ++++++------------ .../PublicErrorContractsTests.swift | 93 ++-- Tests/OMSWalletTests/RequestsTests.swift | 62 +-- .../SignatureVerificationTests.swift | 19 +- Tests/OMSWalletTests/TronWalletTests.swift | 78 ---- Tests/OMSWalletTests/WalletImportTests.swift | 66 +-- 8 files changed, 275 insertions(+), 594 deletions(-) diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift index b01a605..04db119 100644 --- a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -33,18 +33,13 @@ private func storedRecordJSON(version: Int = 2, walletJSON: String) -> String { """ } -@Test func TestActiveWalletAndSessionAreNilWhenSignedOut() throws { - let fixture = makeMockWalletClient() - - #expect(fixture.client.activeWallet == nil) - #expect(fixture.client.session == nil) -} - @Test func TestCompletedAuthDefinesActiveWalletAndSessionTogether() async throws { let fixture = makeMockWalletClient() let wallet = testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111") try fixture.transport.enqueue(completeAuthResponse(wallets: [wallet]), for: WaasAPI.CompleteAuth.urlPath) try fixture.transport.enqueue(UseWalletResponse(wallet: wallet), for: WaasAPI.UseWallet.urlPath) + #expect(fixture.client.activeWallet == nil) + #expect(fixture.client.session == nil) let result = try await fixture.client.completeEmailAuth(code: "123456") @@ -111,7 +106,12 @@ private func storedRecordJSON(version: Int = 2, walletJSON: String) -> String { @Test(arguments: [ #"{"id":"wallet-1","type":"ethereum","address":"0xdef","keyOrigin":"enclave"}"#, #"{"id":"wallet-1","type":"bitcoin","address":"bc1q","keyOrigin":"enclave"}"#, - #"{"id":"wallet-1","type":"tron","address":"TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL","keyOrigin":"derived"}"# + #"{"id":"wallet-1","type":"tron","address":"TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL","keyOrigin":"derived"}"#, + #"{"id":"wallet-1","type":"ethereum","address":"0x1111111111111111111111111111111111111111","keyOrigin":"derived"}"#, + #"{"id":"wallet-1","type":"ethereum","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"#, + #"{"id":"","type":"tron","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","type":"tron","address":"","keyOrigin":"enclave"}"#, + #"{"id":"wallet-1","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"# ]) func TestDiscardsStoredSessionsWithInvalidWallets(walletJSON: String) throws { let fixture = try makeFixture(storedRecordJSON: storedRecordJSON(walletJSON: walletJSON)) diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index b8f899a..be6f0c4 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -623,29 +623,14 @@ func TestGetTronBalancesRejectsInvalidResponses(responseBody: String) async thro } } -@Test func TestGetBalancesRejectsMissingRequiredContractBalanceFields() async throws { - let recorder = IndexerRequestRecorder( - responseBody: Data( - #"{"page":{"page":0,"pageSize":40,"more":false},"nativeBalances":[],"balances":[{"chainId":137,"results":[{"contractType":"ERC20","contractAddress":"0xtoken","accountAddress":"0xwallet","tokenID":"0","balance":"1","blockNumber":1,"chainId":137}]}]}"#.utf8 - ) - ) - let client = makeRecordingIndexerClient(recorder: recorder) - - do { - _ = try await client.getBalances(GetBalancesParams(walletAddress: "0xwallet")) - #expect(Bool(false), "Expected an invalid response error") - } catch let error as OMSWalletError { - #expect(error.code == .invalidResponse) - #expect(error.operation == .indexerGetBalances) - } -} - @Test func TestGetBalancesRejectsMissingOrNullRequiredResultContainers() async throws { let responseBodies = [ #"{"balances":[]}"#, #"{"nativeBalances":null,"balances":[]}"#, #"{"nativeBalances":[{"chainId":137}],"balances":[]}"#, - #"{"nativeBalances":[{"chainId":137,"results":null}],"balances":[]}"# + #"{"nativeBalances":[{"chainId":137,"results":null}],"balances":[]}"#, + // A contract balance missing its required contractInfo fields. + #"{"page":{"page":0,"pageSize":40,"more":false},"nativeBalances":[],"balances":[{"chainId":137,"results":[{"contractType":"ERC20","contractAddress":"0xtoken","accountAddress":"0xwallet","tokenID":"0","balance":"1","blockNumber":1,"chainId":137}]}]}"# ] for responseBody in responseBodies { @@ -662,29 +647,14 @@ func TestGetTronBalancesRejectsInvalidResponses(responseBody: String) async thro } } -@Test func TestGetTransactionHistoryRejectsMissingTransfers() async throws { - let recorder = IndexerRequestRecorder( - responseBody: Data( - #"{"transactions":[{"chainId":137,"results":[{"txnHash":"0xtxn","blockNumber":1,"blockHash":"0xblock","chainId":137,"timestamp":"2026-01-01T00:00:00Z"}]}]}"#.utf8 - ) - ) - let client = makeRecordingIndexerClient(recorder: recorder) - - do { - _ = try await client.getTransactionHistory(GetTransactionHistoryParams(walletAddress: "0xwallet")) - #expect(Bool(false), "Expected an invalid response error") - } catch let error as OMSWalletError { - #expect(error.code == .invalidResponse) - #expect(error.operation == .indexerGetTransactionHistory) - } -} - @Test func TestGetTransactionHistoryRejectsMissingOrNullRequiredResultContainers() async throws { let responseBodies = [ #"{}"#, #"{"transactions":null}"#, #"{"transactions":[{"chainId":137}]}"#, - #"{"transactions":[{"chainId":137,"results":null}]}"# + #"{"transactions":[{"chainId":137,"results":null}]}"#, + // A transaction missing its required transfers. + #"{"transactions":[{"chainId":137,"results":[{"txnHash":"0xtxn","blockNumber":1,"blockHash":"0xblock","chainId":137,"timestamp":"2026-01-01T00:00:00Z"}]}]}"# ] for responseBody in responseBodies { @@ -701,57 +671,6 @@ func TestGetTronBalancesRejectsInvalidResponses(responseBody: String) async thro } } -@Test func TestIndexerNonSuccessResponsesThrowOmsHttpError() async throws { - let tokenBalancesRecorder = IndexerRequestRecorder( - statusCode: 500, - responseBody: Data(#"{"msg":"gateway unavailable"}"#.utf8) - ) - let tokenBalancesClient = makeRecordingIndexerClient(recorder: tokenBalancesRecorder) - - do { - _ = try await tokenBalancesClient.getBalances( - GetBalancesParams( - walletAddress: "0xwallet", - networks: [.polygon], - includeMetadata: true - ) - ) - #expect(Bool(false), "Expected indexer HTTP error") - } catch let error as OMSWalletError { - #expect(error.code == .httpError) - #expect(error.operation == .indexerGetBalances) - #expect(error.status == 500) - #expect(error.retryable == true) - #expect(error.localizedDescription == "gateway unavailable") - } catch { - #expect(Bool(false), "Expected OMSWalletError") - } - - let historyRecorder = IndexerRequestRecorder( - statusCode: 404, - responseBody: Data(#"{"cause":"not found"}"#.utf8) - ) - let historyClient = makeRecordingIndexerClient(recorder: historyRecorder) - - do { - _ = try await historyClient.getTransactionHistory( - GetTransactionHistoryParams( - walletAddress: "0xwallet", - networks: [.polygon] - ) - ) - #expect(Bool(false), "Expected indexer HTTP error") - } catch let error as OMSWalletError { - #expect(error.code == .httpError) - #expect(error.operation == .indexerGetTransactionHistory) - #expect(error.status == 404) - #expect(error.retryable == false) - #expect(error.localizedDescription == "indexer.getTransactionHistory failed with HTTP 404") - } catch { - #expect(Bool(false), "Expected OMSWalletError") - } -} - @available(macOS 12.0, iOS 15.0, *) func makeRecordingIndexerClient(recorder: IndexerRequestRecorder) -> IndexerClient { let configuration = URLSessionConfiguration.ephemeral diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 94302f2..b747cf1 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -195,62 +195,99 @@ private func isOidcAuth( #expect(isEmailAuth(fixture.client.session?.auth)) } -@Test func TestWalletSessionLifetimeConstantsDriveDefaultAndLimit() async throws { +enum SessionLifetimeEntryPoint: CaseIterable, CustomTestStringConvertible { + case startEmailAuth + case signInWithOidcIdToken + case startOIDCRedirectAuth + case handleOIDCRedirectCallback + + var testDescription: String { "\(self)" } + + var operation: OMSWalletOperation { + switch self { + case .startEmailAuth: .walletStartEmailAuth + case .signInWithOidcIdToken: .walletSignInWithOidcIdToken + case .startOIDCRedirectAuth: .walletStartOIDCRedirectAuth + case .handleOIDCRedirectCallback: .walletHandleOIDCRedirectCallback + } + } +} + +@Test(arguments: SessionLifetimeEntryPoint.allCases, [UInt32(0), UInt32(2_592_001)]) +func TestWalletAuthEntryPointsRejectInvalidSessionLifetimeBeforeRequestOrSignOut( + _ entryPoint: SessionLifetimeEntryPoint, + sessionLifetimeSeconds: UInt32 +) async throws { #expect(WalletClient.defaultSessionLifetimeSeconds == 604_800) #expect(WalletClient.maxSessionLifetimeSeconds == 2_592_000) - let fixture = makeMockWalletClient() - try fixture.transport.enqueue( - CommitVerifierResponse(verifier: "verifier", loginHint: "", challenge: "challenge"), - for: WaasAPI.CommitVerifier.urlPath - ) - try fixture.transport.enqueue( - completeAuthResponse(wallets: [testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111")]), - for: WaasAPI.CompleteAuth.urlPath - ) - try fixture.transport.enqueue( - UseWalletResponse(wallet: testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111")), - for: WaasAPI.UseWallet.urlPath - ) - - try await fixture.client.startEmailAuth(email: "user@example.com") - _ = try await fixture.client.completeEmailAuth(code: "123456") - let completeAuthRequest = try fixture.transport.decodedRequest( - CompleteAuthRequest.self, - for: WaasAPI.CompleteAuth.urlPath + let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) + let provider = CustomOIDCProviderConfiguration( + issuer: "https://issuer.example", + clientID: "client-123", + authorizationURL: "https://issuer.example/oauth/authorize", + providerRedirectURI: "omsclientswiftdemo://auth/callback" ) - #expect(completeAuthRequest.lifetime == WalletClient.defaultSessionLifetimeSeconds) - - do { - try await fixture.client.startEmailAuth( - email: "user@example.com", - sessionLifetimeSeconds: WalletClient.maxSessionLifetimeSeconds + 1 + var started: StartOIDCRedirectAuthResult? + if entryPoint == .handleOIDCRedirectCallback { + try fixture.transport.enqueue( + CommitVerifierResponse( + verifier: "oidc-verifier-123", + loginHint: "user@example.com", + challenge: "pkce-challenge" + ), + for: WaasAPI.CommitVerifier.urlPath ) - Issue.record("Expected a validation error") - } catch let error as OMSWalletError { - #expect(error.code == .validationError) + started = try await fixture.client.startOIDCRedirectAuth(provider: provider) } -} - -@Test func TestWalletStartEmailAuthRejectsInvalidSessionLifetimeBeforeRequestOrSignOut() async throws { - let fixture = makeMockWalletClient() let wallet = testWallet(id: "wallet-existing", address: "0x1111111111111111111111111111111111111111") fixture.client.activeWallet = activeTestWallet(wallet.address, id: wallet.id) do { - try await fixture.client.startEmailAuth(email: "user@example.com", sessionLifetimeSeconds: 0) - #expect(Bool(false)) + switch entryPoint { + case .startEmailAuth: + try await fixture.client.startEmailAuth( + email: "user@example.com", + sessionLifetimeSeconds: sessionLifetimeSeconds + ) + case .signInWithOidcIdToken: + _ = try await fixture.client.signInWithOidcIdToken( + idToken: try fakeOidcIdToken(), + issuer: "https://accounts.google.com", + audience: "demo-web-client-id", + sessionLifetimeSeconds: sessionLifetimeSeconds + ) + case .startOIDCRedirectAuth: + _ = try await fixture.client.startOIDCRedirectAuth( + provider: provider, + sessionLifetimeSeconds: sessionLifetimeSeconds + ) + case .handleOIDCRedirectCallback: + let state = try oidcState(try #require(started)) + _ = try await fixture.client.handleOIDCRedirectCallback( + "omsclientswiftdemo://auth/callback?code=auth-code&state=\(state)", + sessionLifetimeSeconds: sessionLifetimeSeconds + ) + } + Issue.record("Expected a validation error") } catch let error as OMSWalletError { #expect(error.code == .validationError) - #expect(error.operation == .walletStartEmailAuth) + #expect(error.operation == entryPoint.operation) #expect(error.localizedDescription == "sessionLifetimeSeconds must be an integer between 1 and 2592000") - } catch { - #expect(Bool(false)) } - #expect(fixture.transport.requestCount(for: WaasAPI.CommitVerifier.urlPath) == 0) - #expect(fixture.client.walletId == wallet.id) - #expect(fixture.client.activeWallet?.address == wallet.address) + let expectedCommitVerifierCount = entryPoint == .handleOIDCRedirectCallback ? 1 : 0 + #expect(fixture.transport.requestCount(for: WaasAPI.CommitVerifier.urlPath) == expectedCommitVerifierCount) + #expect(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 0) + if entryPoint != .handleOIDCRedirectCallback { + // Start-style entry points validate before touching the current session. The callback + // has already consumed its pending flow, so its failure path clears the session. + #expect(fixture.client.walletId == wallet.id) + #expect(fixture.client.activeWallet?.address == wallet.address) + } + if entryPoint == .startOIDCRedirectAuth { + #expect(fixture.oidcRedirectAuthStore.pending == nil) + } } @Test func TestWalletStartingEmailAuthAgainReplacesPendingSessionLifetime() async throws { @@ -908,28 +945,6 @@ private func waitForSessionExpiredEvent( #expect(isOidcAuth(storedCredentials?.auth, flow: .idToken)) } -@Test func TestWalletSignInWithOidcIdTokenRejectsInvalidSessionLifetimeBeforeRequest() async throws { - let fixture = makeMockWalletClient() - - do { - _ = try await fixture.client.signInWithOidcIdToken( - idToken: try fakeOidcIdToken(), - issuer: "https://accounts.google.com", - audience: "demo-web-client-id", - sessionLifetimeSeconds: 2_592_001 - ) - #expect(Bool(false)) - } catch let error as OMSWalletError { - #expect(error.code == .validationError) - #expect(error.operation == .walletSignInWithOidcIdToken) - #expect(error.localizedDescription == "sessionLifetimeSeconds must be an integer between 1 and 2592000") - } catch { - #expect(Bool(false)) - } - - #expect(fixture.transport.requestCount(for: WaasAPI.CommitVerifier.urlPath) == 0) -} - @Test func TestWalletSignInWithOidcIdTokenCanReturnManualWalletSelection() async throws { let fixture = makeMockWalletClient() let availableWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") @@ -1112,18 +1127,6 @@ private func waitForSessionExpiredEvent( #expect(try fixture.storedCredentials() == nil) } -@Test func TestCustomOIDCProviderConfigurationDefaultsToNoScopesForCustomProviders() { - let provider = CustomOIDCProviderConfiguration( - issuer: "https://issuer.example", - clientID: "client-123", - authorizationURL: "https://issuer.example/oauth/authorize", - providerRedirectURI: "https://issuer.example/oauth/callback" - ) - - #expect(provider.scopes == []) - #expect(provider.authMode == .authCodePKCE) -} - @Test func TestWalletStartOIDCRedirectAuthCustomGoogleProviderUsesProviderRedirectURI() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) try fixture.transport.enqueue( @@ -1178,6 +1181,9 @@ private func waitForSessionExpiredEvent( providerRedirectURI: "https://provider.example/callback", authorizeParams: ["prompt": "consent"] ) + // Custom providers default to no scopes and PKCE. + #expect(provider.scopes == []) + #expect(provider.authMode == .authCodePKCE) let result = try await fixture.client.startOIDCRedirectAuth( provider: provider, @@ -1223,32 +1229,6 @@ private func waitForSessionExpiredEvent( #expect(fixture.client.verifier == "oidc-verifier-123") } -@Test func TestWalletStartOidcRedirectAuthRejectsInvalidSessionLifetimeBeforeRequest() async throws { - let fixture = makeMockWalletClient() - - do { - _ = try await fixture.client.startOIDCRedirectAuth( - provider: CustomOIDCProviderConfiguration( - issuer: "https://issuer.example", - clientID: "client-123", - authorizationURL: "https://issuer.example/oauth/authorize", - providerRedirectURI: "omsclientswiftdemo://auth/callback" - ), - sessionLifetimeSeconds: 2_592_001 - ) - #expect(Bool(false)) - } catch let error as OMSWalletError { - #expect(error.code == .validationError) - #expect(error.operation == .walletStartOIDCRedirectAuth) - #expect(error.localizedDescription == "sessionLifetimeSeconds must be an integer between 1 and 2592000") - } catch { - #expect(Bool(false)) - } - - #expect(fixture.transport.requestCount(for: WaasAPI.CommitVerifier.urlPath) == 0) - #expect(fixture.oidcRedirectAuthStore.pending == nil) -} - @Test func TestWalletStartOidcRedirectAuthUsesAppleDefaultsThroughRelay() async throws { let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) try fixture.transport.enqueue( @@ -1715,39 +1695,6 @@ private func waitForSessionExpiredEvent( #expect(fixture.oidcRedirectAuthStore.pending == nil) } -@Test func TestWalletHandleOidcRedirectCallbackRejectsInvalidSessionLifetimeBeforeRequest() async throws { - let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) - try fixture.transport.enqueue( - CommitVerifierResponse( - verifier: "oidc-verifier-123", - loginHint: "user@example.com", - challenge: "pkce-challenge" - ), - for: WaasAPI.CommitVerifier.urlPath - ) - let started = try await fixture.client.startOIDCRedirectAuth( - provider: CustomOIDCProviderConfiguration( - issuer: "https://issuer.example", - clientID: "client-123", - authorizationURL: "https://issuer.example/oauth/authorize", - providerRedirectURI: "omsclientswiftdemo://auth/callback" - ) - ) - - do { - _ = try await fixture.client.handleOIDCRedirectCallback( - "omsclientswiftdemo://auth/callback?code=auth-code&state=\(try oidcState(started))", - sessionLifetimeSeconds: 0 - ) - Issue.record("Expected invalid session lifetime error") - } catch let error as OMSWalletError { - #expect(error.code == .validationError) - #expect(error.operation == .walletHandleOIDCRedirectCallback) - #expect(error.localizedDescription == "sessionLifetimeSeconds must be an integer between 1 and 2592000") - } - #expect(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 0) -} - /// Follows an ignored callback with the real one and checks it still completes sign-in, which /// fails if the ignored callback consumed or cleared the pending redirect. private func expectRealOidcCallbackStillCompletes( @@ -2118,6 +2065,17 @@ private func expectRealOidcCallbackStillCompletes( pages.append(page) } let manualPage = try await fixture.client.listAccessPage(pageSize: 2, cursor: "manual") + + // listAccess combines every page into one list. + try fixture.transport.enqueue( + WaasListAccessResponse(credentials: [firstCredential], page: WaasPage(cursor: "next")), + for: WaasAPI.ListAccess.urlPath + ) + try fixture.transport.enqueue( + WaasListAccessResponse(credentials: [secondCredential]), + for: WaasAPI.ListAccess.urlPath + ) + let combined = try await fixture.client.listAccess(pageSize: 25) let listAccessRequests = try fixture.transport.decodedRequests( ListAccessRequest.self, for: WaasAPI.ListAccess.urlPath @@ -2130,7 +2088,8 @@ private func expectRealOidcCallbackStillCompletes( #expect(pages[1].page?.cursor == nil) #expect(manualPage.grants.map(\.credential.credentialId) == ["credential-3"]) #expect(manualPage.page?.cursor == "after-manual") - #expect(listAccessRequests.count == 3) + #expect(combined.map(\.credential.credentialId) == ["credential-1", "credential-2"]) + #expect(listAccessRequests.count == 5) #expect(listAccessRequests[0].walletId == "wallet-main") #expect(listAccessRequests[0].page?.limit == 1) #expect(listAccessRequests[0].page?.cursor == nil) @@ -2140,52 +2099,10 @@ private func expectRealOidcCallbackStillCompletes( #expect(listAccessRequests[2].walletId == "wallet-main") #expect(listAccessRequests[2].page?.limit == 2) #expect(listAccessRequests[2].page?.cursor == "manual") -} - -@Test func TestWalletListAccessReturnsCombinedCredentialPages() async throws { - let fixture = makeMockWalletClient() - fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") - - try fixture.transport.enqueue( - WaasListAccessResponse( - credentials: [ - WaasCredentialInfo( - credentialId: "credential-1", - type: .direct, - expiresAt: "2026-01-01T00:00:00Z", - isCaller: true - ) - ], - page: WaasPage(cursor: "next") - ), - for: WaasAPI.ListAccess.urlPath - ) - try fixture.transport.enqueue( - WaasListAccessResponse( - credentials: [ - WaasCredentialInfo( - credentialId: "credential-2", - type: .direct, - expiresAt: "2026-01-02T00:00:00Z", - isCaller: false - ) - ] - ), - for: WaasAPI.ListAccess.urlPath - ) - - let credentials = try await fixture.client.listAccess(pageSize: 25) - let listAccessRequests = try fixture.transport.decodedRequests( - ListAccessRequest.self, - for: WaasAPI.ListAccess.urlPath - ) - - #expect(credentials.map(\.credential.credentialId) == ["credential-1", "credential-2"]) - #expect(listAccessRequests.count == 2) - #expect(listAccessRequests[0].page?.limit == 25) - #expect(listAccessRequests[0].page?.cursor == nil) - #expect(listAccessRequests[1].page?.limit == 25) - #expect(listAccessRequests[1].page?.cursor == "next") + #expect(listAccessRequests[3].page?.limit == 25) + #expect(listAccessRequests[3].page?.cursor == nil) + #expect(listAccessRequests[4].page?.limit == 25) + #expect(listAccessRequests[4].page?.cursor == "next") } @Test func TestWalletOwnerSmartSessionFlowUsesPublicAndSignedWaasMethods() async throws { @@ -2615,48 +2532,34 @@ private func expectRealOidcCallbackStillCompletes( #expect(Bool(false), "Expected TransactionError.noFeeOptionSelected") } -@Test func TestFeeOptionSelectorFirstAvailableRejectsMalformedAvailableRaw() async throws { - let feeOption = testFeeOptions()[0] - - let selection = try await FeeOptionSelector.firstAvailable([ - FeeOptionWithBalance( - feeOption: feeOption, - availableRaw: "not-a-number" - ) - ]) - - #expect(selection == nil) -} - -@Test func TestFeeOptionSelectorFirstAvailableRejectsMalformedFeeValue() async throws { +@Test(arguments: [ + // (fee value override, availableRaw, selected) + (nil as String?, "not-a-number", false), + ("not-a-number", "100000", false), + (nil, "000100", true), +]) +func TestFeeOptionSelectorFirstAvailableParsesUnsignedDecimals( + feeValue: String?, + availableRaw: String, + selected: Bool +) async throws { let baseFeeOption = testFeeOptions()[0] - let malformedFeeOption = FeeOption( - token: baseFeeOption.token, - value: "not-a-number", - displayValue: "invalid" - ) - - let selection = try await FeeOptionSelector.firstAvailable([ - FeeOptionWithBalance( - feeOption: malformedFeeOption, - availableRaw: "100000" - ) - ]) - - #expect(selection == nil) -} - -@Test func TestFeeOptionSelectorFirstAvailableAcceptsValidUnsignedDecimals() async throws { - let feeOption = testFeeOptions()[0] + let feeOption = feeValue.map { + FeeOption(token: baseFeeOption.token, value: $0, displayValue: "invalid") + } ?? baseFeeOption let selection = try await FeeOptionSelector.firstAvailable([ FeeOptionWithBalance( feeOption: feeOption, - availableRaw: "000100" + availableRaw: availableRaw ) ]) - #expect(selection?.token == FeeOptionWithBalance(feeOption: feeOption).selection.token) + if selected { + #expect(selection?.token == FeeOptionWithBalance(feeOption: feeOption).selection.token) + } else { + #expect(selection == nil) + } } @Test func TestWalletSendTransactionCustomSelectorReceivesFeeOptionBalances() async throws { @@ -2931,7 +2834,8 @@ private func expectRealOidcCallbackStillCompletes( #expect(executeRequest.feeOption?.token == "POL") } -@Test func TestWalletSendTransactionCanSkipStatusPolling() async throws { +@Test(arguments: [false, true]) +func TestWalletSendTransactionCanSkipStatusPolling(viaCallContract: Bool) async throws { let fixture = makeMockWalletClient() fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") @@ -2943,18 +2847,28 @@ private func expectRealOidcCallbackStillCompletes( sponsored: true, expiresAt: "2026-04-27T00:00:00Z" ), - for: WaasAPI.PrepareEthereumTransaction.urlPath + for: viaCallContract + ? WaasAPI.PrepareEthereumContractCall.urlPath + : WaasAPI.PrepareEthereumTransaction.urlPath ) try fixture.transport.enqueue( ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath ) - let txResult = try await fixture.client.sendTransaction( - network: .amoy, - request: SendTransactionRequest(to: "0xabc", value: "0"), - waitForStatus: false - ) + let txResult = viaCallContract + ? try await fixture.client.callContract( + network: .amoy, + contractAddress: "0xcontract", + method: "mint", + args: [AbiArg(type: "address", value: .string("0xrecipient"))], + waitForStatus: false + ) + : try await fixture.client.sendTransaction( + network: .amoy, + request: SendTransactionRequest(to: "0xabc", value: "0"), + waitForStatus: false + ) #expect(txResult.txnId == "txn-no-poll-1") #expect(txResult.status == .pending) @@ -2963,41 +2877,14 @@ private func expectRealOidcCallbackStillCompletes( #expect(fixture.transport.requestCount(for: WaasAPI.TransactionStatusMethod.urlPath) == 0) } -@Test func TestWalletCallContractCanSkipStatusPolling() async throws { - let fixture = makeMockWalletClient() - fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") - - try fixture.transport.enqueue( - PrepareResponse( - txnId: "txn-contract-no-poll-1", - status: .quoted, - feeOptions: [], - sponsored: true, - expiresAt: "2026-04-27T00:00:00Z" - ), - for: WaasAPI.PrepareEthereumContractCall.urlPath - ) - try fixture.transport.enqueue( - ExecuteResponse(status: .pending), - for: WaasAPI.Execute.urlPath - ) - - let txResult = try await fixture.client.callContract( - network: .amoy, - contractAddress: "0xcontract", - method: "mint", - args: [AbiArg(type: "address", value: .string("0xrecipient"))], - waitForStatus: false - ) - - #expect(txResult.txnId == "txn-contract-no-poll-1") - #expect(txResult.status == .pending) - #expect(txResult.txnHash == nil) - #expect(txResult.statusResolution == .notRequested) - #expect(fixture.transport.requestCount(for: WaasAPI.TransactionStatusMethod.urlPath) == 0) -} - -@Test func TestWalletSendTransactionMarksTimedOutWhenPollingDeadlineExpires() async throws { +@Test(arguments: [ + WaasGenerated.TransactionStatus.pending, + .quoted, + .unknown("future-status"), +]) +func TestWalletSendTransactionMarksTimedOutWhenPollingDeadlineExpires( + status: WaasGenerated.TransactionStatus +) async throws { let fixture = makeMockWalletClient() fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") @@ -3016,7 +2903,7 @@ private func expectRealOidcCallbackStillCompletes( for: WaasAPI.Execute.urlPath ) try fixture.transport.enqueue( - WaasTransactionStatusResponse(status: .pending), + WaasTransactionStatusResponse(status: status), for: WaasAPI.TransactionStatusMethod.urlPath ) @@ -3029,49 +2916,12 @@ private func expectRealOidcCallbackStillCompletes( ) #expect(txResult.txnId == "txn-pending-1") - #expect(txResult.status == .pending) + #expect(txResult.status == status.sdkValue) #expect(txResult.txnHash == nil) #expect(txResult.statusResolution == .timedOut) #expect(fixture.transport.requestCount(for: WaasAPI.TransactionStatusMethod.urlPath) == 1) } -@Test func TestWalletSendTransactionReturnsTimedOutNonterminalStatuses() async throws { - let statuses: [WaasGenerated.TransactionStatus] = [.quoted, .unknown("future-status")] - - for (index, status) in statuses.enumerated() { - let fixture = makeMockWalletClient() - fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") - try fixture.transport.enqueue( - PrepareResponse( - txnId: "txn-timeout-\(index)", - status: .quoted, - feeOptions: [], - sponsored: true, - expiresAt: "2026-04-27T00:00:00Z" - ), - for: WaasAPI.PrepareEthereumTransaction.urlPath - ) - try fixture.transport.enqueue( - ExecuteResponse(status: .pending), - for: WaasAPI.Execute.urlPath - ) - try fixture.transport.enqueue( - WaasTransactionStatusResponse(status: status), - for: WaasAPI.TransactionStatusMethod.urlPath - ) - - let result = try await fixture.client.sendTransaction( - network: .amoy, - request: SendTransactionRequest(to: "0xabc", value: "0"), - statusPolling: TransactionStatusPollingOptions(timeoutMs: 0) - ) - - #expect(result.txnId == "txn-timeout-\(index)") - #expect(result.status == status.sdkValue) - #expect(result.statusResolution == .timedOut) - } -} - @Test func TestWalletSendTransactionRejectsInvalidPollingOptionsBeforeExecute() async throws { let invalidOptions = [ TransactionStatusPollingOptions(intervalMs: 0), diff --git a/Tests/OMSWalletTests/PublicErrorContractsTests.swift b/Tests/OMSWalletTests/PublicErrorContractsTests.swift index aa20e44..31e39b5 100644 --- a/Tests/OMSWalletTests/PublicErrorContractsTests.swift +++ b/Tests/OMSWalletTests/PublicErrorContractsTests.swift @@ -135,14 +135,6 @@ import Testing #expect(fixture.client.activeWallet?.id == "wallet-main") } -@Test func TestPublicErrorContractsWireValues() { - #expect(OMSWalletErrorCode.walletAddressAlreadyImported.rawValue == "OMS_WALLET_ADDRESS_ALREADY_IMPORTED") - #expect(OMSWalletOperation.walletStartOIDCRedirectAuth.rawValue == "wallet.startOidcRedirectAuth") - #expect(OMSWalletOperation.walletHandleOIDCRedirectCallback.rawValue == "wallet.handleOidcRedirectCallback") - #expect(OMSWalletUpstreamService.waas.rawValue == "waas") - #expect(OMSWalletUpstreamService.indexer.rawValue == "indexer") -} - @Test func TestPublicErrorContractsWaasHttpAndBadResponsesHaveUpstreamDetails() async throws { let httpFixture = makeRestoredWalletClient() httpFixture.transport.enqueueRawHTTPError( @@ -822,6 +814,56 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda ) ) + // A `msg` field is used as the message when `message` is absent. + let msgFieldClient = makeRecordingIndexerClient( + recorder: IndexerRequestRecorder( + statusCode: 500, + responseBody: Data(#"{"msg":"gateway unavailable"}"#.utf8) + ) + ) + await expectPublicError( + try await msgFieldClient.getBalances( + GetBalancesParams(walletAddress: "0xwallet", networks: [.polygon], includeMetadata: true) + ), + equals: error( + code: .httpError, + operation: .indexerGetBalances, + message: "gateway unavailable", + status: 500, + retryable: true, + upstreamError: upstream( + service: .indexer, + message: "gateway unavailable", + status: 500 + ) + ) + ) + + // A 4xx without a usable message is not retryable and gets the generic HTTP message. + let notFoundClient = makeRecordingIndexerClient( + recorder: IndexerRequestRecorder( + statusCode: 404, + responseBody: Data(#"{"cause":"not found"}"#.utf8) + ) + ) + await expectPublicError( + try await notFoundClient.getTransactionHistory( + GetTransactionHistoryParams(walletAddress: "0xwallet", networks: [.polygon]) + ), + equals: error( + code: .httpError, + operation: .indexerGetTransactionHistory, + message: "indexer.getTransactionHistory failed with HTTP 404", + status: 404, + retryable: false, + upstreamError: upstream( + service: .indexer, + message: "indexer.getTransactionHistory failed with HTTP 404", + status: 404 + ) + ) + ) + let nonJsonClient = makeRecordingIndexerClient( recorder: IndexerRequestRecorder( statusCode: 502, @@ -910,41 +952,6 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda #expect(invalidUrlFailure.upstreamError == nil) } -@Test func TestPublicErrorContractsConstructedErrorFieldsAreStable() { - let upstreamError = OMSWalletUpstreamError( - service: .waas, - name: "WebrpcRequestFailed", - code: "-1", - message: "request failed", - status: nil - ) - let sdkError = OMSWalletError( - code: .requestFailed, - message: "request failed", - operation: .walletStartEmailAuth, - status: nil, - txnId: "txn-1", - retryable: true, - upstreamError: upstreamError - ) - - #expect(serialize(sdkError) == PublicErrorContract( - code: .requestFailed, - operation: .walletStartEmailAuth, - message: "request failed", - status: nil, - retryable: true, - txnId: "txn-1", - upstreamError: SerializedUpstreamError( - service: .waas, - name: "WebrpcRequestFailed", - code: "-1", - message: "request failed", - status: nil - ) - )) -} - private func makeRestoredWalletClient() -> MockWalletClientFixture { let fixture = makeMockWalletClient() fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") diff --git a/Tests/OMSWalletTests/RequestsTests.swift b/Tests/OMSWalletTests/RequestsTests.swift index c5efa66..672dfc1 100644 --- a/Tests/OMSWalletTests/RequestsTests.swift +++ b/Tests/OMSWalletTests/RequestsTests.swift @@ -38,27 +38,24 @@ import Testing #expect(OidcIdToken.handleHash(idToken) == "eFZOodqrVYyRrQXoMyRVFyFSAj4PsRNgR8ktA8AaeUI") } -@Test func TestOidcIdTokenRequiresExpirationClaim() throws { - let idToken = try fakeOidcIdToken(payload: ["sub": "oidc-sub-123"]) - - do { - _ = try OidcIdToken.expiresAtEpochSeconds(idToken) - #expect(Bool(false)) - } catch let error as OidcIdTokenError { - #expect(error == .missingExpiration) - } catch { - #expect(Bool(false)) - } -} - -@Test func TestOidcIdTokenRejectsOutOfRangeNumericExpirationClaim() throws { - let idToken = try fakeOidcIdToken(payload: ["exp": 1e100]) +@Test(arguments: [ + (#"{"sub":"oidc-sub-123"}"#, OidcIdTokenError.missingExpiration), + (#"{"exp":1e100}"#, .invalidExpiration), +]) +func TestOidcIdTokenRequiresValidExpirationClaim( + payloadJSON: String, + expectedError: OidcIdTokenError +) throws { + let payload = try #require( + try JSONSerialization.jsonObject(with: Data(payloadJSON.utf8)) as? [String: Any] + ) + let idToken = try fakeOidcIdToken(payload: payload) do { _ = try OidcIdToken.expiresAtEpochSeconds(idToken) #expect(Bool(false)) } catch let error as OidcIdTokenError { - #expect(error == .invalidExpiration) + #expect(error == expectedError) } catch { #expect(Bool(false)) } @@ -147,37 +144,6 @@ import Testing #expect(restored.auth == .email(OMSWalletEmailSessionAuth(email: "user@example.com"))) } -@Test func TestStorableCredentialsRejectsVersionOneRecords() { - let versionOne = """ - {"walletId":"wallet-1","walletAddress":"0x1111111111111111111111111111111111111111",\ - "signerCredentialId":"0xsigner","alg":"ecdsa-p256-sha256","expiresAt":"2099-01-01T00:00:00Z",\ - "auth":{"type":"email","email":"user@example.com"}} - """ - - #expect(throws: DecodingError.self) { - try StorableCredentials.from(jsonString: versionOne) - } -} - -@Test(arguments: [ - #"{"id":"wallet-1","type":"bitcoin","address":"bc1q","keyOrigin":"enclave"}"#, - #"{"id":"wallet-1","type":"ethereum","address":"0x1111111111111111111111111111111111111111","keyOrigin":"derived"}"#, - #"{"id":"wallet-1","type":"ethereum","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"#, - #"{"id":"","type":"tron","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"#, - #"{"id":"wallet-1","type":"tron","address":"","keyOrigin":"enclave"}"#, - #"{"id":"wallet-1","address":"TW39NT9SCCv7aomYYXgh4wcUWag4XtVe2H","keyOrigin":"enclave"}"# -]) -func TestStorableCredentialsRejectsInvalidStoredWallets(walletJSON: String) { - let record = """ - {"version":2,"wallet":\(walletJSON),"signerCredentialId":"0xsigner","alg":"ecdsa-p256-sha256",\ - "expiresAt":"2099-01-01T00:00:00Z","auth":{"type":"email","email":"user@example.com"}} - """ - - #expect(throws: (any Error).self) { - try StorableCredentials.from(jsonString: record) - } -} - @Test func TestOidcRedirectAuthMatchesCustomSchemesWithoutAuthority() throws { #expect( OIDCRedirectAuth.matchesRedirectURI( @@ -187,7 +153,7 @@ func TestStorableCredentialsRejectsInvalidStoredWallets(walletJSON: String) { ) } -@Test func TestOidcRedirectAuthRejectsInvalidStateWithoutClearingPendingAuth() throws { +@Test func TestOidcRedirectAuthValidateStateRejectsNonBase64State() throws { let pending = PendingOIDCRedirectAuth( verifier: "verifier", challenge: "challenge", diff --git a/Tests/OMSWalletTests/SignatureVerificationTests.swift b/Tests/OMSWalletTests/SignatureVerificationTests.swift index bdd6389..762f7c6 100644 --- a/Tests/OMSWalletTests/SignatureVerificationTests.swift +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -53,10 +53,14 @@ enum SignatureVerificationMethod: CaseIterable, CustomTestStringConvertible { } func enqueueValidResponse(_ transport: MockWaasTransport) throws { + try enqueueResponse(transport, isValid: true) + } + + func enqueueResponse(_ transport: MockWaasTransport, isValid: Bool) throws { if isTypedData { - try transport.enqueue(IsValidTypedDataSignatureResponse(isValid: true), for: path) + try transport.enqueue(IsValidTypedDataSignatureResponse(isValid: isValid), for: path) } else { - try transport.enqueue(IsValidMessageSignatureResponse(isValid: true), for: path) + try transport.enqueue(IsValidMessageSignatureResponse(isValid: isValid), for: path) } } @@ -123,15 +127,18 @@ private func activate(_ fixture: MockWalletClientFixture, type: WalletType, addr fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) } -@Test(arguments: SignatureVerificationMethod.allCases) -func TestSignatureVerificationWithAddressWorksWhileSignedOut(_ method: SignatureVerificationMethod) async throws { +@Test(arguments: SignatureVerificationMethod.allCases, [true, false]) +func TestSignatureVerificationWithAddressWorksWhileSignedOut( + _ method: SignatureVerificationMethod, + expectedIsValid: Bool +) async throws { let fixture = makeMockWalletClient() - try method.enqueueValidResponse(fixture.transport) + try method.enqueueResponse(fixture.transport, isValid: expectedIsValid) let isValid = try await method.call(fixture.client, walletAddress: method.address) let body = try fixture.transport.requestObject(for: method.path) - #expect(isValid) + #expect(isValid == expectedIsValid) #expect(body == method.expectedBody(walletAddress: method.address)) #expect(body["walletId"] == nil) } diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift index 1869eb8..a5f3bf3 100644 --- a/Tests/OMSWalletTests/TronWalletTests.swift +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -392,48 +392,6 @@ func TestContractCallsRejectNonBareMethodNamesBeforeAnyRequest(method: String) a #expect(signTypedData["typedData"] as? NSDictionary == expectedTypedData) } -@Test func TestTronSignatureValidationUsesTronNetworkFamily() async throws { - let fixture = makeMockWalletClient() - try fixture.transport.enqueue( - IsValidMessageSignatureResponse(isValid: true), - for: WaasPublicAPI.IsValidMessageSignature.urlPath - ) - try fixture.transport.enqueue( - IsValidTypedDataSignatureResponse(isValid: false), - for: WaasPublicAPI.IsValidTypedDataSignature.urlPath - ) - - let messageValid = try await fixture.client.isValidTronMessageSignature( - walletAddress: tronWalletAddress, - message: "hello", - signature: "0xsig" - ) - let typedDataValid = try await fixture.client.isValidTronTypedDataSignature( - walletAddress: tronWalletAddress, - typedData: .object(["primaryType": .string("Mail")]), - signature: "0xsig" - ) - - let message = try fixture.transport.requestObject(for: WaasPublicAPI.IsValidMessageSignature.urlPath) - let typedData = try fixture.transport.requestObject(for: WaasPublicAPI.IsValidTypedDataSignature.urlPath) - let expectedMessage: NSDictionary = [ - "networkFamily": "tron", - "walletAddress": tronWalletAddress, - "message": "hello", - "signature": "0xsig" - ] - let expectedTypedData: NSDictionary = [ - "networkFamily": "tron", - "walletAddress": tronWalletAddress, - "typedData": ["primaryType": "Mail"], - "signature": "0xsig" - ] - #expect(messageValid) - #expect(!typedDataValid) - #expect(message == expectedMessage) - #expect(typedData == expectedTypedData) -} - @Test(arguments: [ (WalletType.ethereum, "0x9999999999999999999999999999999999999999"), (WalletType.solana, "4Nd1mYQbqjVU2aR7cJNPyqW9XjHnBYvWQd7ZxYxvT6uP") @@ -544,39 +502,3 @@ func TestTronOperationsRejectNonTronWalletsBeforeAnyRequest(type: WalletType, ad #expect(result.wallet == expected) #expect(fixture.client.activeWallet == expected) } - -@Test func TestTronPrivateKeyImportUsesSecp256k1Validation() throws { - let key = String(repeating: "11", count: 32) - #expect(try WalletImportValidation.plaintext(.tron(" 0x\(key)\n")) == Data("0x\(key)".utf8)) - #expect(try WalletImportValidation.plaintext(.tron(key)) == Data(key.utf8)) - let one = Data(repeating: 0, count: 31) + Data([1]) - #expect(try WalletImportValidation.plaintext(.tronBytes(one)) == one) - - let cases: [(WalletImportPrivateKey, String)] = [ - (.tron("0x1234"), "Tron privateKey must be 32 bytes or 64 hexadecimal characters"), - (.tronBytes(Data(repeating: 1, count: 31)), "Tron privateKey must contain exactly 32 bytes"), - (.tronBytes(Data(repeating: 0, count: 32)), "Tron privateKey is outside the valid secp256k1 scalar range"), - ( - .tron("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), - "Tron privateKey is outside the valid secp256k1 scalar range" - ), - (.ethereum("0x1234"), "Ethereum privateKey must be 32 bytes or 64 hexadecimal characters") - ] - for (privateKey, message) in cases { - do { - _ = try WalletImportValidation.plaintext(privateKey) - Issue.record("Expected \(message)") - } catch let error as OMSWalletError { - #expect(error.code == .validationError) - #expect(error.localizedDescription == message) - } - } -} - -@Test func TestTronNetworksUseWaasNetworkIdentifiers() { - #expect(TronNetwork.allCases == [.mainnet, .nile]) - #expect(TronNetworks.mainnet.rawValue == "tron:mainnet") - #expect(TronNetworks.nile.rawValue == "tron:nile") - #expect(WalletType(wireValue: "tron") == .tron) - #expect(WalletType.tron.wireValue == "tron") -} diff --git a/Tests/OMSWalletTests/WalletImportTests.swift b/Tests/OMSWalletTests/WalletImportTests.swift index 5a07eba..2916352 100644 --- a/Tests/OMSWalletTests/WalletImportTests.swift +++ b/Tests/OMSWalletTests/WalletImportTests.swift @@ -5,24 +5,47 @@ import Testing @Test func TestWalletImportPrivateKeyValidationCoversScalarAndLengthBoundaries() throws { let one = Data(repeating: 0, count: 31) + Data([1]) + let tronKey = String(repeating: "11", count: 32) #expect(try WalletImportValidation.plaintext(.ethereumBytes(one)) == one) - #expect(throws: OMSWalletError.self) { - _ = try WalletImportValidation.plaintext(.ethereumBytes(Data(repeating: 0, count: 32))) - } - #expect(throws: OMSWalletError.self) { - _ = try WalletImportValidation.plaintext( - .ethereum("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") - ) - } + #expect(try WalletImportValidation.plaintext(.tronBytes(one)) == one) + #expect(try WalletImportValidation.plaintext(.tron(" 0x\(tronKey)\n")) == Data("0x\(tronKey)".utf8)) + #expect(try WalletImportValidation.plaintext(.tron(tronKey)) == Data(tronKey.utf8)) #expect(try WalletImportValidation.plaintext(.solanaBytes(Data(repeating: 7, count: 64))).count == 64) - #expect(throws: OMSWalletError.self) { - _ = try WalletImportValidation.plaintext(.solanaBytes(Data(repeating: 7, count: 31))) - } - #expect(throws: OMSWalletError.self) { - _ = try WalletImportValidation.plaintext(.solana(String(repeating: "1", count: 32))) + + let secp256k1Order = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" + let cases: [(WalletImportPrivateKey, String)] = [ + (.ethereum("0x1234"), "Ethereum privateKey must be 32 bytes or 64 hexadecimal characters"), + (.ethereumBytes(Data(repeating: 0, count: 32)), "Ethereum privateKey is outside the valid secp256k1 scalar range"), + (.ethereum(secp256k1Order), "Ethereum privateKey is outside the valid secp256k1 scalar range"), + (.tron("0x1234"), "Tron privateKey must be 32 bytes or 64 hexadecimal characters"), + (.tronBytes(Data(repeating: 1, count: 31)), "Tron privateKey must contain exactly 32 bytes"), + (.tronBytes(Data(repeating: 0, count: 32)), "Tron privateKey is outside the valid secp256k1 scalar range"), + (.tron(secp256k1Order), "Tron privateKey is outside the valid secp256k1 scalar range"), + ( + .solanaBytes(Data(repeating: 7, count: 31)), + "Solana privateKey must contain a 32-byte seed or 64-byte keypair" + ), + ( + .solana(String(repeating: "1", count: 32)), + "Solana privateKey string is ambiguous; provide the raw bytes instead" + ), + ] + for (privateKey, message) in cases { + do { + _ = try WalletImportValidation.plaintext(privateKey) + Issue.record("Expected \(message)") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + #expect(error.localizedDescription == message) + } } - #expect(throws: OMSWalletError.self) { - _ = try WalletImportValidation.validateReference(String(repeating: "é", count: 65)) + + do { + try WalletImportValidation.validateReference(String(repeating: "é", count: 65)) + Issue.record("Expected an oversized reference to be rejected") + } catch let error as OMSWalletError { + #expect(error.code == .validationError) + #expect(error.localizedDescription == "reference must be at most 128 UTF-8 bytes") } } @@ -127,19 +150,6 @@ import Testing } } -@Test func TestWalletImportP256HpkeProducesStandardEncapsulationAndAuthenticatedCiphertext() throws { - let recipient = P256.KeyAgreement.PrivateKey() - let plaintext = Data("0x".utf8) + Data(repeating: 0x31, count: 64) - let sealed = try P256HPKE.seal( - recipientPublicKey: recipient.publicKey.derRepresentation, - plaintext: plaintext - ) - - #expect(sealed.encapsulatedKey.count == 65) - #expect(sealed.encapsulatedKey.first == 4) - #expect(sealed.ciphertext.count == plaintext.count + 16) -} - private func verifySyntheticAttestation( timestamp: Date, pcr0: Data, From eedabb432fc45de7843e007aa5a6e3956061bf13 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 16 Oct 2026 19:11:08 +0300 Subject: [PATCH 30/35] test: share test fixtures Move exact-duplicate helpers into the shared fixtures in MockWalletTests.swift: one requestBodyData reader replaces three copies of the URL-protocol bodyData helper (and the dead MockIndexerURLProtocol copy), activateTestSession backs the three active-session helpers, testEnvironment replaces the copied default environment, and LockedBox moves out of the Tron tests. The URL protocols themselves differ in behaviour and stay separate. --- .../ActiveWalletSessionTests.swift | 5 - Tests/OMSWalletTests/IndexerTests.swift | 28 +--- Tests/OMSWalletTests/MockWalletTests.swift | 120 ++++++++++-------- .../PublicErrorContractsTests.swift | 4 +- .../SignatureVerificationTests.swift | 4 +- Tests/OMSWalletTests/TronWalletTests.swift | 19 +-- .../WaasRequestSigningTests.swift | 28 +--- 7 files changed, 70 insertions(+), 138 deletions(-) diff --git a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift index 04db119..acddbb4 100644 --- a/Tests/OMSWalletTests/ActiveWalletSessionTests.swift +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -2,11 +2,6 @@ import Foundation import Testing @testable import OMSWallet -private let testEnvironment = OMSWalletEnvironment( - walletApiUrl: "https://wallet.example.test", - indexerGatewayUrl: "https://indexer.example.test/v1/IndexerGateway/" -) - private func makeFixture( storedRecordJSON: String, projectId: String = "proj_\(UUID().uuidString)", diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index be6f0c4..5dc7263 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -756,7 +756,7 @@ final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { override func startLoading() { let recorder = Self.recorder(for: request) - recorder?.record(request: request, body: Self.bodyData(for: request)) + recorder?.record(request: request, body: requestBodyData(request)) if let error = recorder?.transportError { client?.urlProtocol(self, didFailWithError: error) @@ -790,30 +790,4 @@ final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { return recordersByHost.first { host.hasSuffix("-\($0.key)") }?.value } - private static func bodyData(for request: URLRequest) -> Data? { - if let body = request.httpBody { - return body - } - - guard let stream = request.httpBodyStream else { - return nil - } - - stream.open() - defer { stream.close() } - - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1024) - - while true { - let count = stream.read(&buffer, maxLength: buffer.count) - if count > 0 { - data.append(buffer, count: count) - } else { - break - } - } - - return data - } } diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index b747cf1..51c6468 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -451,10 +451,7 @@ func TestWalletAuthEntryPointsRejectInvalidSessionLifetimeBeforeRequestOrSignOut } @Test func TestWalletCredentialSessionPurgesInvalidStoredAuthMetadata() throws { - let environment = OMSWalletEnvironment( - walletApiUrl: "https://wallet.example.test", - indexerGatewayUrl: "https://indexer.example.test/v1/IndexerGateway/" - ) + let environment = testEnvironment let projectId = "project-invalid-session" let keychain = InMemoryKeychain() let signer = MockCredentialSigner() @@ -3154,11 +3151,68 @@ struct MockWalletClientFixture { } } +/// The default wallet and indexer endpoints used by `makeMockWalletClient`. +let testEnvironment = OMSWalletEnvironment( + walletApiUrl: "https://wallet.example.test", + indexerGatewayUrl: "https://indexer.example.test/v1/IndexerGateway/" +) + +/// Seeds an active wallet session directly, without going through an auth flow. +func activateTestSession( + _ fixture: MockWalletClientFixture, + wallet: Wallet, + expiresAt: String = "2099-01-01T00:00:00Z" +) { + fixture.client.activeWallet = wallet + fixture.client.sessionExpiresAt = expiresAt + fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) +} + +final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withValue(_ body: (inout Value) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body(&value) + } +} + +/// Reads a URL-loading request body, which `URLSession` may deliver as a stream. +func requestBodyData(_ request: URLRequest) -> Data? { + if let body = request.httpBody { + return body + } + + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + if count > 0 { + data.append(buffer, count: count) + } else { + break + } + } + + return data +} + func makeMockWalletClient( - environment: OMSWalletEnvironment = OMSWalletEnvironment( - walletApiUrl: "https://wallet.example.test", - indexerGatewayUrl: "https://indexer.example.test/v1/IndexerGateway/" - ), + environment: OMSWalletEnvironment = testEnvironment, projectId: String = "proj_\(UUID().uuidString)", keychain: InMemoryKeychain = InMemoryKeychain(), signer: MockCredentialSigner = MockCredentialSigner(), @@ -3446,14 +3500,14 @@ final class MockIndexerBackend: @unchecked Sendable { func responseBody(for request: URLRequest) -> Data { withLock { if request.url?.path.contains("/TronIndexerGateway/") == true { - tronBalanceRequestBodies.append(Self.bodyData(for: request) ?? Data()) + tronBalanceRequestBodies.append(requestBodyData(request) ?? Data()) return tronBalancesResponse } if request.url?.path.contains("/SolanaIndexerGateway/") == true { - solanaBalanceRequests.append(decodeBalanceRequest(Self.bodyData(for: request))) + solanaBalanceRequests.append(decodeBalanceRequest(requestBodyData(request))) return solanaBalancesResponse } - let requestBody = Self.bodyData(for: request) + let requestBody = requestBodyData(request) let request = decodeBalanceRequest(requestBody) balanceRequests.append(request) let tokenBalances = request.contractAddresses.flatMap { contractAddress in @@ -3477,24 +3531,6 @@ final class MockIndexerBackend: @unchecked Sendable { } } - private static func bodyData(for request: URLRequest) -> Data? { - if let body = request.httpBody { - return body - } - guard let stream = request.httpBodyStream else { return nil } - stream.open() - defer { stream.close() } - var data = Data() - let buffer = UnsafeMutablePointer.allocate(capacity: 1_024) - defer { buffer.deallocate() } - while stream.hasBytesAvailable { - let count = stream.read(buffer, maxLength: 1_024) - guard count > 0 else { break } - data.append(buffer, count: count) - } - return data - } - private func withLock(_ body: () -> T) -> T { lock.lock() defer { lock.unlock() } @@ -3563,32 +3599,6 @@ final class MockIndexerURLProtocol: URLProtocol, @unchecked Sendable { return backendsByHost[host] } - private static func bodyData(for request: URLRequest) -> Data? { - if let body = request.httpBody { - return body - } - - guard let stream = request.httpBodyStream else { - return nil - } - - stream.open() - defer { stream.close() } - - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1024) - - while true { - let count = stream.read(&buffer, maxLength: buffer.count) - if count > 0 { - data.append(buffer, count: count) - } else { - break - } - } - - return data - } } private func uriOriginAndPath(_ url: String) -> String { diff --git a/Tests/OMSWalletTests/PublicErrorContractsTests.swift b/Tests/OMSWalletTests/PublicErrorContractsTests.swift index 31e39b5..29847d5 100644 --- a/Tests/OMSWalletTests/PublicErrorContractsTests.swift +++ b/Tests/OMSWalletTests/PublicErrorContractsTests.swift @@ -954,9 +954,7 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda private func makeRestoredWalletClient() -> MockWalletClientFixture { let fixture = makeMockWalletClient() - fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") - fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" - fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + activateTestSession(fixture, wallet: activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main")) return fixture } diff --git a/Tests/OMSWalletTests/SignatureVerificationTests.swift b/Tests/OMSWalletTests/SignatureVerificationTests.swift index 762f7c6..a5d79dc 100644 --- a/Tests/OMSWalletTests/SignatureVerificationTests.swift +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -122,9 +122,7 @@ enum SignatureVerificationMethod: CaseIterable, CustomTestStringConvertible { } private func activate(_ fixture: MockWalletClientFixture, type: WalletType, address: String) { - fixture.client.activeWallet = Wallet(id: "wallet-active", type: type, address: address, keyOrigin: .enclave) - fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" - fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + activateTestSession(fixture, wallet: Wallet(id: "wallet-active", type: type, address: address, keyOrigin: .enclave)) } @Test(arguments: SignatureVerificationMethod.allCases, [true, false]) diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift index a5f3bf3..9af4a27 100644 --- a/Tests/OMSWalletTests/TronWalletTests.swift +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -8,29 +8,12 @@ private let tronWalletHex = "0x8840e6c55b9ada326d211d818c34a994aeced808" private let tronRecipient = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" private let tronUsdt = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" -final class LockedBox: @unchecked Sendable { - private let lock = NSLock() - private var value: Value - - init(_ value: Value) { - self.value = value - } - - func withValue(_ body: (inout Value) -> T) -> T { - lock.lock() - defer { lock.unlock() } - return body(&value) - } -} - private func makeActiveWalletFixture( type: WalletType = .tron, address: String = tronWalletAddress ) -> MockWalletClientFixture { let fixture = makeMockWalletClient() - fixture.client.activeWallet = Wallet(id: "wallet-id", type: type, address: address, keyOrigin: .enclave) - fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" - fixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + activateTestSession(fixture, wallet: Wallet(id: "wallet-id", type: type, address: address, keyOrigin: .enclave)) return fixture } diff --git a/Tests/OMSWalletTests/WaasRequestSigningTests.swift b/Tests/OMSWalletTests/WaasRequestSigningTests.swift index 7ce50d6..7bd84de 100644 --- a/Tests/OMSWalletTests/WaasRequestSigningTests.swift +++ b/Tests/OMSWalletTests/WaasRequestSigningTests.swift @@ -241,7 +241,7 @@ private final class WaasRecordingURLProtocol: URLProtocol, @unchecked Sendable { override func startLoading() { let recorder = Self.recorder(for: request) - recorder?.record(request: request, body: Self.bodyData(for: request)) + recorder?.record(request: request, body: requestBodyData(request)) let response = HTTPURLResponse( url: request.url!, @@ -266,32 +266,6 @@ private final class WaasRecordingURLProtocol: URLProtocol, @unchecked Sendable { return recordersByHost[host] } - private static func bodyData(for request: URLRequest) -> Data? { - if let body = request.httpBody { - return body - } - - guard let stream = request.httpBodyStream else { - return nil - } - - stream.open() - defer { stream.close() } - - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1024) - - while true { - let count = stream.read(&buffer, maxLength: buffer.count) - if count > 0 { - data.append(buffer, count: count) - } else { - break - } - } - - return data - } } @available(macOS 12.0, iOS 15.0, *) From 752ef95b2298157a5586a385b522117430c68b76 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Mon, 19 Oct 2026 16:31:40 +0300 Subject: [PATCH 31/35] feat(demo): switch wallet type after sign-in --- .../oms-wallet-demo/ContentView.swift | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift index bfaa76a..46de158 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -249,7 +249,10 @@ final class AppViewModel: ObservableObject { @Published var sessionExpiredPrompt: SessionExpiredPrompt? @Published fileprivate var feeOptionSelectionRequest: FeeOptionSelectionRequest? @Published var useManualWalletSelection: Bool = false + /// Type of the active wallet, shown by the wallet screen's type switcher. @Published var selectedWalletType: DemoWalletType = .ethereum + /// Type the wallet screen is switching to while `switchWalletType` runs. + @Published var switchingWalletType: DemoWalletType? @Published var loginEmail: String = "" @Published var sessionLifetimeText: String = "604800" @Published var omsWallet: OMSWallet = try! OMSWallet( @@ -267,8 +270,12 @@ final class AppViewModel: ObservableObject { useManualWalletSelection ? .manual : .automatic } + /// Wallet family to sign in with. Fresh sign-ins use the SDK default (Ethereum) and switch + /// type on the wallet screen; reauthenticating an expired session keeps its wallet family. + private var reauthWalletType: WalletType? + private var walletType: WalletType { - selectedWalletType.walletType + reauthWalletType ?? .ethereum } var sessionLifetimeSeconds: UInt32? { @@ -282,11 +289,46 @@ final class AppViewModel: ObservableObject { } func checkSession() async { - let activeWallet = omsWallet.wallet.activeWallet - if let walletType = DemoWalletType(activeWallet?.type) { + if omsWallet.wallet.activeWallet != nil { + showWallet() + } else { + screen = .introduction + } + } + + /// Shows the wallet screen for the active wallet; the restored or newly signed-in wallet + /// keeps its own type. + private func showWallet() { + reauthWalletType = nil + if let walletType = DemoWalletType(omsWallet.wallet.activeWallet?.type) { selectedWalletType = walletType } - screen = activeWallet != nil ? .wallet : .introduction + screen = .wallet + } + + /// Activates the account's first wallet of `type`, creating one when none exists. + func switchWalletType(to type: DemoWalletType) async { + guard switchingWalletType == nil, + omsWallet.wallet.activeWallet?.type != type.walletType else { + return + } + + switchingWalletType = type + defer { switchingWalletType = nil } + + do { + let existing = try await omsWallet.wallet.listWallets().first { $0.type == type.walletType } + if let existing { + try await omsWallet.wallet.useWallet(walletId: existing.id) + } else { + try await omsWallet.wallet.createWallet(walletType: type.walletType) + } + if let walletType = DemoWalletType(omsWallet.wallet.activeWallet?.type) { + selectedWalletType = walletType + } + } catch { + present(error) + } } func signOut() { @@ -297,6 +339,7 @@ final class AppViewModel: ObservableObject { try omsWallet.wallet.signOut() safariAuthSession = nil sessionExpiredPrompt = nil + reauthWalletType = nil screen = .introduction } catch { present(error) @@ -354,7 +397,7 @@ final class AppViewModel: ObservableObject { switch result { case .walletSelected: - screen = .wallet + showWallet() case .walletSelection(let pendingSelection): screen = .walletSelection(pendingSelection) } @@ -462,7 +505,7 @@ final class AppViewModel: ObservableObject { switch result { case .completed(.walletSelected): safariAuthSession = nil - screen = .wallet + showWallet() case .completed(.walletSelection(let pendingSelection)): safariAuthSession = nil screen = .walletSelection(pendingSelection) @@ -489,7 +532,7 @@ final class AppViewModel: ObservableObject { ) switch result { case .walletSelected: - screen = .wallet + showWallet() case .walletSelection(let pendingSelection): screen = .walletSelection(pendingSelection) } @@ -518,6 +561,7 @@ final class AppViewModel: ObservableObject { } catch { present(error) } + reauthWalletType = nil screen = .login } @@ -529,7 +573,7 @@ final class AppViewModel: ObservableObject { do { _ = try await operation() - screen = .wallet + showWallet() } catch { present(error) } @@ -576,9 +620,7 @@ final class AppViewModel: ObservableObject { loginEmail = email } // Sign the user back in to the same wallet family. - if let walletType = DemoWalletType(event.wallet?.type) { - selectedWalletType = walletType - } + reauthWalletType = DemoWalletType(event.wallet?.type)?.walletType screen = .login sessionExpiredPrompt = SessionExpiredPrompt(event: event) } @@ -778,9 +820,6 @@ struct LoginWindow: View { } .padding(.top, 16) - WalletTypePicker() - .padding(.top, 16) - ManualWalletSelectionToggle() .padding(.top, 16) @@ -1026,18 +1065,39 @@ enum DemoWalletType: String, CaseIterable, Identifiable { } } -private struct WalletTypePicker: View { +/// Switches the signed-in account between its Ethereum, Solana, and Tron wallets. +private struct WalletTypeSwitcher: View { @EnvironmentObject private var vm: AppViewModel + var isDisabled: Bool = false + + private var selection: Binding { + Binding( + get: { vm.switchingWalletType ?? vm.selectedWalletType }, + set: { type in + Task { await vm.switchWalletType(to: type) } + } + ) + } var body: some View { - FieldGroup(title: "Wallet type", titleStyle: .secondary) { - Picker("Wallet type", selection: $vm.selectedWalletType) { + FieldGroup(title: "Wallet type") { + Picker("Wallet type", selection: selection) { ForEach(DemoWalletType.allCases) { type in Text(type.rawValue).tag(type) } } .pickerStyle(.segmented) .labelsHidden() + .disabled(isDisabled || vm.switchingWalletType != nil || vm.feeOptionSelectionRequest != nil) + + if let switching = vm.switchingWalletType { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + .tint(DesignTokens.Color.info) + DesignText("Switching to a \(switching.rawValue) wallet...", variant: .caption) + } + } } } } @@ -1214,6 +1274,7 @@ struct WalletWindow: View { ScrollView { VStack(spacing: 24) { walletHeader + WalletTypeSwitcher(isDisabled: isFetchingBalance) walletAddressBar assetSection } @@ -1580,10 +1641,12 @@ private struct TronBalancesWindow: View { subtitle: "Tron wallets use native mode only. Transactions are often sponsored by each account's daily free bandwidth; once it is spent, the network fee is paid in TRX.", showsCloseButton: false ) { + WalletTypeSwitcher(isDisabled: isBusy || isFetchingBalance) + DemoNetworkPicker( selection: $network, isMainnet: network == .mainnet, - isDisabled: isBusy || vm.feeOptionSelectionRequest != nil + isDisabled: isBusy || vm.feeOptionSelectionRequest != nil || vm.switchingWalletType != nil ) if let address = vm.omsWallet.wallet.activeWallet?.address { @@ -2104,10 +2167,12 @@ private struct SolanaBalancesWindow: View { subtitle: "Transfers are relayed, so the network fee is sponsored. Sending an SPL token to a wallet without a token account asks you to choose how to pay its rent.", showsCloseButton: false ) { + WalletTypeSwitcher(isDisabled: isBusy || isFetchingBalance) + DemoNetworkPicker( selection: $network, isMainnet: network == .mainnet, - isDisabled: isBusy || vm.feeOptionSelectionRequest != nil + isDisabled: isBusy || vm.feeOptionSelectionRequest != nil || vm.switchingWalletType != nil ) if let address = vm.omsWallet.wallet.activeWallet?.address { From f98a083d92b1cc0a17ff13580dc0443cbd5cbca2 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Mon, 19 Oct 2026 20:11:01 +0300 Subject: [PATCH 32/35] test: remove assertions that cannot fail --- Tests/OMSWalletTests/IndexerTests.swift | 4 ---- Tests/OMSWalletTests/MockWalletTests.swift | 21 ------------------- Tests/OMSWalletTests/RequestsTests.swift | 4 +--- .../SignatureVerificationTests.swift | 2 -- Tests/OMSWalletTests/TronWalletTests.swift | 1 - .../WalletImportHPKETests.swift | 10 --------- 6 files changed, 1 insertion(+), 41 deletions(-) diff --git a/Tests/OMSWalletTests/IndexerTests.swift b/Tests/OMSWalletTests/IndexerTests.swift index 5dc7263..55349bd 100644 --- a/Tests/OMSWalletTests/IndexerTests.swift +++ b/Tests/OMSWalletTests/IndexerTests.swift @@ -28,17 +28,13 @@ import Testing #expect(Network.findByName("amoy") == .amoy) #expect(Network.findByName(" AMOY ") == .amoy) #expect(Network.findByName("polygonamoy") == nil) - #expect(Network.findByName("polygonAmoy") == nil) #expect(Network(rawValue: "arbitrum-sepolia") == .arbitrumSepolia) - #expect(Network(rawValue: "amoy") == .amoy) #expect(Network.polygon.displayName == "Polygon") #expect(Network.polygon.description == "Polygon") #expect(Network.polygon.id == 137) - #expect(Network.polygon.name == "polygon") #expect(Network.polygon.nativeTokenSymbol == "POL") #expect(Network.polygon.explorerUrl == "https://polygonscan.com") - #expect(Network.amoy.name == "amoy") #expect(Network.amoy.id == 80002) #expect(Network.amoy.displayName == "Polygon Amoy") #expect(Network.findById(80002) == .amoy) diff --git a/Tests/OMSWalletTests/MockWalletTests.swift b/Tests/OMSWalletTests/MockWalletTests.swift index 51c6468..b1083ad 100644 --- a/Tests/OMSWalletTests/MockWalletTests.swift +++ b/Tests/OMSWalletTests/MockWalletTests.swift @@ -112,7 +112,6 @@ private func isOidcAuth( #expect(fixture.client.walletId == firstWallet.id) #expect(fixture.client.activeWallet?.address == firstWallet.address) #expect(fixture.client.session != nil) - #expect(fixture.client.activeWallet?.address == firstWallet.address) #expect(storedCredentials?.wallet.id == firstWallet.id) #expect(storedCredentials?.wallet.address == firstWallet.address) #expect(isEmailAuth(storedCredentials?.auth)) @@ -1178,10 +1177,6 @@ private func waitForSessionExpiredEvent( providerRedirectURI: "https://provider.example/callback", authorizeParams: ["prompt": "consent"] ) - // Custom providers default to no scopes and PKCE. - #expect(provider.scopes == []) - #expect(provider.authMode == .authCodePKCE) - let result = try await fixture.client.startOIDCRedirectAuth( provider: provider, authorizeParams: ["prompt": "select_account", "audience": "wallet"] @@ -1949,20 +1944,6 @@ private func expectRealOidcCallbackStillCompletes( await expectNoAuthenticatedWalletSession { try await fixture.client.signTypedData(network: .amoy, typedData: .object([:])) } - await expectNoAuthenticatedWalletSession { - try await fixture.client.isValidMessageSignature( - network: .amoy, - message: "hello", - signature: "0xsig" - ) - } - await expectNoAuthenticatedWalletSession { - try await fixture.client.isValidTypedDataSignature( - network: .amoy, - typedData: .object([:]), - signature: "0xsig" - ) - } await expectNoAuthenticatedWalletSession { try await fixture.client.sendTransaction( network: .amoy, @@ -1991,8 +1972,6 @@ private func expectRealOidcCallbackStillCompletes( WaasAPI.RevokeAccess.urlPath, WaasAPI.SignMessage.urlPath, WaasAPI.SignTypedData.urlPath, - WaasPublicAPI.IsValidMessageSignature.urlPath, - WaasPublicAPI.IsValidTypedDataSignature.urlPath, WaasAPI.PrepareEthereumTransaction.urlPath, WaasAPI.PrepareEthereumContractCall.urlPath ] diff --git a/Tests/OMSWalletTests/RequestsTests.swift b/Tests/OMSWalletTests/RequestsTests.swift index 672dfc1..13a2e6e 100644 --- a/Tests/OMSWalletTests/RequestsTests.swift +++ b/Tests/OMSWalletTests/RequestsTests.swift @@ -61,7 +61,7 @@ func TestOidcIdTokenRequiresValidExpirationClaim( } } -@Test func TestP256RawSignatureDerRoundTrip() throws { +@Test func TestP256DerSignatureConvertsToRaw() throws { let expectedRawSignature = Array(repeating: UInt8(0), count: 31) + [UInt8(0x80)] + Array(repeating: UInt8(0), count: 31) @@ -106,11 +106,9 @@ func TestOidcIdTokenRequiresValidExpirationClaim( auth: .email(OMSWalletEmailSessionAuth(email: "user@example.com")) ) - #expect(session.expiresAt == "2026-01-01T00:00:00Z") #expect(OMSWalletSession.parseDate(session.expiresAt) == Date(timeIntervalSince1970: 1_767_225_600)) #expect(OMSWalletSession.parseDate("2026-01-01T00:00:00.500Z") == Date(timeIntervalSince1970: 1_767_225_600.5)) #expect(OMSWalletSession.parseDate("not-a-date") == nil) - #expect(session.auth.email == "user@example.com") } @Test func TestStorableCredentialsRoundTripSessionMetadata() throws { diff --git a/Tests/OMSWalletTests/SignatureVerificationTests.swift b/Tests/OMSWalletTests/SignatureVerificationTests.swift index a5d79dc..0251137 100644 --- a/Tests/OMSWalletTests/SignatureVerificationTests.swift +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -138,7 +138,6 @@ func TestSignatureVerificationWithAddressWorksWhileSignedOut( let body = try fixture.transport.requestObject(for: method.path) #expect(isValid == expectedIsValid) #expect(body == method.expectedBody(walletAddress: method.address)) - #expect(body["walletId"] == nil) } @Test(arguments: SignatureVerificationMethod.allCases) @@ -165,7 +164,6 @@ func TestSignatureVerificationWithoutAddressUsesActiveWalletAddress(_ method: Si let body = try fixture.transport.requestObject(for: method.path) #expect(isValid) #expect(body == method.expectedBody(walletAddress: method.address)) - #expect(body["walletId"] == nil) } @Test(arguments: SignatureVerificationMethod.allCases) diff --git a/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift index 9af4a27..07fe2d5 100644 --- a/Tests/OMSWalletTests/TronWalletTests.swift +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -67,7 +67,6 @@ private func expectValidationError( "mode": "native" ] #expect(prepare == expectedPrepare) - #expect(prepare["data"] == nil) #expect(execute == (["txnId": "txn-trx"] as NSDictionary)) #expect(result.txnId == "txn-trx") #expect(result.status == .executed) diff --git a/Tests/OMSWalletTests/WalletImportHPKETests.swift b/Tests/OMSWalletTests/WalletImportHPKETests.swift index 4cd1807..1e350fb 100644 --- a/Tests/OMSWalletTests/WalletImportHPKETests.swift +++ b/Tests/OMSWalletTests/WalletImportHPKETests.swift @@ -99,14 +99,4 @@ private func hex(_ data: Data) -> String { #expect(throws: (any Error).self) { try tamperedRecipient.open(tampered, authenticating: Data()) } - - // CryptoKit also opens the RFC 9180 vector ciphertext, cross-checking the vector itself. - typealias Vector = RFC9180P256Sha256Aes256GcmBase - var vectorRecipient = try HPKE.Recipient( - privateKey: P256.KeyAgreement.PrivateKey(rawRepresentation: hex(Vector.skRm)), - ciphersuite: .P256_SHA256_AES_GCM_256, - info: hex(Vector.info), - encapsulatedKey: hex(Vector.enc) - ) - #expect(try vectorRecipient.open(hex(Vector.ciphertext), authenticating: hex(Vector.aad)) == hex(Vector.plaintext)) } From 9a4be8cd273f2098fecd8f987e2b8407386eaec3 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Mon, 19 Oct 2026 20:22:15 +0300 Subject: [PATCH 33/35] chore(release): prepare 0.4.0 --- README.md | 6 +++--- oms-wallet-swift-sdk.podspec | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2376eac..f045c42 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ Add the package in Xcode with **File -> Add Package Dependencies** and enter the https://github.com/0xsequence/swift-sdk.git ``` -Use the dependency rule **Up to Next Minor Version** with version `0.3.1`. While the SDK is +Use the dependency rule **Up to Next Minor Version** with version `0.4.0`. While the SDK is pre-1.0, minor releases can contain breaking changes (see [MIGRATION.md](MIGRATION.md)). To add the package from a `Package.swift` manifest instead: ```swift dependencies: [ - .package(url: "https://github.com/0xsequence/swift-sdk.git", .upToNextMinor(from: "0.3.1")) + .package(url: "https://github.com/0xsequence/swift-sdk.git", .upToNextMinor(from: "0.4.0")) ], targets: [ .target( @@ -46,7 +46,7 @@ targets: [ Add the pod to your `Podfile`: ```ruby -pod 'oms-wallet-swift-sdk', '0.3.1' +pod 'oms-wallet-swift-sdk', '0.4.0' ``` ## Quick Start diff --git a/oms-wallet-swift-sdk.podspec b/oms-wallet-swift-sdk.podspec index ad2adaf..2351035 100644 --- a/oms-wallet-swift-sdk.podspec +++ b/oms-wallet-swift-sdk.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "oms-wallet-swift-sdk" - s.version = "0.3.1" + s.version = "0.4.0" s.summary = "OMS Wallet Swift SDK." s.description = <<-DESC OMS Wallet Swift SDK provides email, OIDC ID-token, and OIDC redirect wallet authentication, From 905b1db69c9731ae2e2b9dcd9759ee0ffca0c907 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 23 Oct 2026 17:09:01 +0300 Subject: [PATCH 34/35] chore(waas): regenerate client from waas 54d91262 Copy proto/clients/waas.gen.swift from waas origin/master (54d91262, schema v1-26.10.19-5824b4d1). The client now comes from webrpc-gen v0.47.0's built-in swift target instead of gen-swift v0.1.4. The change is additive (GetCredential, ClaimWallet, Wallets.claimable, WalletStatus.preallocated, error 7208 SessionCapExceeded), so SDK code needs no changes. --- Sources/OMSWallet/Generated/waas.gen.swift | 132 +++++++++++++++++++-- 1 file changed, 125 insertions(+), 7 deletions(-) diff --git a/Sources/OMSWallet/Generated/waas.gen.swift b/Sources/OMSWallet/Generated/waas.gen.swift index e60e5f2..a3e43f9 100644 --- a/Sources/OMSWallet/Generated/waas.gen.swift +++ b/Sources/OMSWallet/Generated/waas.gen.swift @@ -2,18 +2,18 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif -// waas v1-26.9.22-f21ed9d2 d92aac00f53699122564bcdbb03aca8afa5c3371 +// waas v1-26.10.19-5824b4d1 156b8b0899604c70abd955abc6d1cef51385acf2 // -// Code generated by webrpc-gen@v0.37.2 with github.com/webrpc/gen-swift@v0.1.4 generator. DO NOT EDIT. +// Code generated by webrpc-gen@v0.47.0 with swift generator. DO NOT EDIT. // -// webrpc-gen -schema=schema/waas.ridl -target=github.com/webrpc/gen-swift@v0.1.4 -client -visibility=internal -namespace=WaasGenerated -out=./clients/waas.gen.swift +// webrpc-gen -schema=schema/waas.ridl -target=swift -client -visibility=internal -namespace=WaasGenerated -out=./clients/waas.gen.swift internal enum WaasGenerated { internal static let WEBRPC_VERSION = "v1" -internal static let WEBRPC_SCHEMA_VERSION = "v1-26.9.22-f21ed9d2" -internal static let WEBRPC_SCHEMA_HASH = "d92aac00f53699122564bcdbb03aca8afa5c3371" +internal static let WEBRPC_SCHEMA_VERSION = "v1-26.10.19-5824b4d1" +internal static let WEBRPC_SCHEMA_HASH = "156b8b0899604c70abd955abc6d1cef51385acf2" internal static let WEBRPC_HEADER = "Webrpc" -internal static let WEBRPC_HEADER_VALUE = "webrpc@v0.37.2;gen-swift@v0.1.4;waas@v1-26.9.22-f21ed9d2" +internal static let WEBRPC_HEADER_VALUE = "webrpc@v0.47.0;swift@v0.47.0;waas@v1-26.10.19-5824b4d1" internal struct WebRPCGeneratedVersions: Sendable, Equatable { internal let webrpcGenVersion: String @@ -376,6 +376,7 @@ internal enum WebRPCErrorKind: Sendable { case usageLimitExceeded case preconditionFailed case unauthorized + case sessionCapExceeded case proofVerificationFailed case answerIncorrect case challengeExpired @@ -451,6 +452,8 @@ internal enum WebRPCErrorKind: Sendable { return 7206 case .unauthorized: return 7207 + case .sessionCapExceeded: + return 7208 case .proofVerificationFailed: return 7002 case .answerIncorrect: @@ -552,6 +555,8 @@ internal enum WebRPCErrorKind: Sendable { return .preconditionFailed case 7207: return .unauthorized + case 7208: + return .sessionCapExceeded case 7002: return .proofVerificationFailed case 7003: @@ -814,6 +819,7 @@ internal enum WalletImplementation: Codable, Hashable, Sendable, WebRPCEnumKey { internal enum WalletStatus: Codable, Hashable, Sendable, WebRPCEnumKey { case active case suspended + case preallocated case unknown(String) internal var wireValue: String { @@ -822,6 +828,8 @@ internal enum WalletStatus: Codable, Hashable, Sendable, WebRPCEnumKey { return "active" case .suspended: return "suspended" + case .preallocated: + return "preallocated" case .unknown(let value): return value } @@ -833,6 +841,8 @@ internal enum WalletStatus: Codable, Hashable, Sendable, WebRPCEnumKey { self = .active case "suspended": self = .suspended + case "preallocated": + self = .preallocated default: self = .unknown(wireValue) } @@ -1700,14 +1710,16 @@ internal struct CompleteAuthResponse: Codable, Sendable { internal let wallets: [Wallet] internal let page: Page? internal let email: String? + internal let claimable: [Wallet]? internal let credential: CredentialInfo - internal init(identity: Identity, wallets: [Wallet], page: Page? = nil, email: String? = nil, credential: CredentialInfo + internal init(identity: Identity, wallets: [Wallet], page: Page? = nil, email: String? = nil, claimable: [Wallet]? = nil, credential: CredentialInfo ) { self.identity = identity self.wallets = wallets self.page = page self.email = email + self.claimable = claimable self.credential = credential } enum CodingKeys: String, CodingKey { @@ -1715,6 +1727,7 @@ internal struct CompleteAuthResponse: Codable, Sendable { case wallets = "wallets" case page = "page" case email = "email" + case claimable = "claimable" case credential = "credential" } } @@ -1829,6 +1842,34 @@ internal struct ImportWalletResponse: Codable, Sendable { +internal struct ClaimWalletRequest: Codable, Sendable { + internal let walletId: String + + internal init(walletId: String + ) { + self.walletId = walletId + } + enum CodingKeys: String, CodingKey { + case walletId = "walletId" + } +} + + + +internal struct ClaimWalletResponse: Codable, Sendable { + internal let wallet: Wallet + + internal init(wallet: Wallet + ) { + self.wallet = wallet + } + enum CodingKeys: String, CodingKey { + case wallet = "wallet" + } +} + + + internal struct UseWalletRequest: Codable, Sendable { internal let walletId: String @@ -2795,6 +2836,33 @@ internal enum WaasAPI { } } + internal enum GetCredential { + internal static let path = "/GetCredential" + internal static let urlPath = "/v1/Waas/GetCredential" + + internal struct Response: Codable, Sendable { + internal let credential: CredentialInfo + + internal init(credential: CredentialInfo + ) { + self.credential = credential + } + enum CodingKeys: String, CodingKey { + case credential = "credential" + } + } + internal static func encodeRequest(encoder _: JSONEncoder = WebRPCJSON.makeEncoder()) throws -> Data { + Data("{}".utf8) + } + + internal static func decodeResponse( + _ data: Data, + decoder: JSONDecoder = WebRPCJSON.makeDecoder() + ) throws -> WaasAPI.GetCredential.Response { + try decoder.decode(WaasAPI.GetCredential.Response.self, from: data) + } + } + internal enum RevokeCredential { internal static let path = "/RevokeCredential" internal static let urlPath = "/v1/Waas/RevokeCredential" @@ -2993,6 +3061,24 @@ internal enum WaasAPI { } } + internal enum ClaimWallet { + internal static let path = "/ClaimWallet" + internal static let urlPath = "/v1/Waas/ClaimWallet" + internal static func encodeRequest( + _ request: ClaimWalletRequest, + encoder: JSONEncoder = WebRPCJSON.makeEncoder() + ) throws -> Data { + try encoder.encode(request) + } + + internal static func decodeResponse( + _ data: Data, + decoder: JSONDecoder = WebRPCJSON.makeDecoder() + ) throws -> ClaimWalletResponse { + try decoder.decode(ClaimWalletResponse.self, from: data) + } + } + internal enum GetRecipientKey { internal static let path = "/GetRecipientKey" internal static let urlPath = "/v1/Waas/GetRecipientKey" @@ -3257,6 +3343,22 @@ internal struct WaasClient: Sendable { ) } + internal func getCredential() async throws -> WaasAPI.GetCredential.Response { + let body = try WaasAPI.GetCredential.encodeRequest() + let requestHeaders = makeWebRPCHeaders( + headers(), + includeWebRPCHeader: true + ) + return try await executeWebRPC( + baseURL: baseURL, + urlPath: WaasAPI.GetCredential.urlPath, + body: body, + transport: transport, + headers: requestHeaders, + decodeSuccess: WaasAPI.GetCredential.decodeResponse, + ) + } + internal func revokeCredential(_ request:RevokeCredentialRequest) async throws -> RevokeCredentialResponse { let body = try WaasAPI.RevokeCredential.encodeRequest(request) let requestHeaders = makeWebRPCHeaders( @@ -3433,6 +3535,22 @@ internal struct WaasClient: Sendable { ) } + internal func claimWallet(_ request:ClaimWalletRequest) async throws -> ClaimWalletResponse { + let body = try WaasAPI.ClaimWallet.encodeRequest(request) + let requestHeaders = makeWebRPCHeaders( + headers(), + includeWebRPCHeader: true + ) + return try await executeWebRPC( + baseURL: baseURL, + urlPath: WaasAPI.ClaimWallet.urlPath, + body: body, + transport: transport, + headers: requestHeaders, + decodeSuccess: WaasAPI.ClaimWallet.decodeResponse, + ) + } + internal func getRecipientKey(_ request:GetRecipientKeyRequest) async throws -> GetRecipientKeyResponse { let body = try WaasAPI.GetRecipientKey.encodeRequest(request) let requestHeaders = makeWebRPCHeaders( From e713298719352a05864a7d6313d1a833bde53e04 Mon Sep 17 00:00:00 2001 From: tolgahan-arikan Date: Fri, 23 Oct 2026 17:33:12 +0300 Subject: [PATCH 35/35] feat(session): refresh the sliding credential expiry WaaS now slides a credential's expiry forward with use (up to the requested lifetime) and exposes GetCredential. The SDK keeps the session expiry in step with the server: - refresh it with getCredential (single-flight, applied only while the same session revision is current, persisted to the stored session, timer rescheduled) after restore, when the expiry timer fires, before an operation uses a locally expired session, and in the background after a signed call when the last refresh or sign-in is 6 hours old - end the session as local expiry does, and throw sessionExpired, when WaaS answers a request signed with the session credential with 7202 KeyNotFound or 7203 KeyExpired; public calls (inspectRemoteCredential, isValid*) and sign-in flows are not mapped - a rejected Execute (refused before its handler runs) throws sessionExpired, not transactionExecutionUnconfirmed; status polling keeps transactionStatusLookupFailed and txnId - keep the session when the refresh is unreachable (timer retries with backoff, operations throw the network error); servers without GetCredential (WebrpcBadRoute) keep local expiry - never overlap a refresh with another session-signed request, so nonces stay increasing - raise maxSessionLifetimeSeconds from 30 to 180 days Docs: README, MIGRATION 0.4.0, error contracts, regenerated API.md. --- AGENTS.md | 4 +- API.md | 10 +- MIGRATION.md | 20 + README.md | 27 +- .../Clients/SessionCredentialTransport.swift | 96 +++++ Sources/OMSWallet/Clients/WalletAuth.swift | 14 +- Sources/OMSWallet/Clients/WalletClient.swift | 61 ++- Sources/OMSWallet/Clients/WalletImports.swift | 28 +- .../OMSWallet/Clients/WalletOperations.swift | 100 ++--- .../OMSWallet/Clients/WalletSessions.swift | 365 ++++++++++++++++-- .../Models/Sessions/OMSWalletSession.swift | 5 +- Sources/OMSWallet/Models/WalletModels.swift | 3 + .../CredentialExpiryRefreshTests.swift | 226 +++++++++++ Tests/OMSWalletTests/MockWalletTests.swift | 73 +++- .../PublicErrorContractsTests.swift | 162 +++++++- docs/error-contracts.md | 4 +- 16 files changed, 1059 insertions(+), 139 deletions(-) create mode 100644 Sources/OMSWallet/Clients/SessionCredentialTransport.swift create mode 100644 Tests/OMSWalletTests/CredentialExpiryRefreshTests.swift diff --git a/AGENTS.md b/AGENTS.md index d812aba..69c8b88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,9 @@ typed-data signing, signature verification, token balances, and unit formatting. - `Package.swift` defines the Swift package, the `"OMSWallet"` library target, and the `"OMSWalletTests"` test target. - `Sources/OMSWallet/` contains the SDK implementation. -- `Sources/OMSWallet/Clients/` contains `WalletClient` and `IndexerClient`. +- `Sources/OMSWallet/Clients/` contains `WalletClient` and `IndexerClient`, plus + `SessionCredentialTransport`, which wraps the session-signed WaaS clients to order their + requests around the SDK's expiry refresh and end the session when WaaS rejects its credential. - `Sources/OMSWallet/Signer/` contains Keychain/P-256 signing and credential session code. - `Sources/OMSWallet/Models/` contains public model types and auth/session diff --git a/API.md b/API.md index fbb1c94..e5fe179 100644 --- a/API.md +++ b/API.md @@ -55,7 +55,7 @@ The session lifetime used when a sign-in call does not pass `sessionLifetimeSeco public static let maxSessionLifetimeSeconds: UInt32 ``` -The longest session lifetime the wallet API accepts (30 days). +The longest session lifetime the wallet API accepts (180 days). ### `WalletClient.addSessionExpiredObserver(_:)` @@ -392,6 +392,9 @@ public struct EncryptedWalletImportKeyMaterial: Equatable, Sendable { ```swift public struct WalletCredential: Codable, Equatable, Sendable { public let credentialId: String + /// The credential's current expiry when it was returned, as an ISO-8601 timestamp. The server + /// extends it with use up to the requested session lifetime, and the SDK refreshes the active + /// session's copy in `OMSWalletSession.expiresAt`. public let expiresAt: String public let isCaller: Bool public init(credentialId: String, expiresAt: String, isCaller: Bool) @@ -572,8 +575,9 @@ Result of handling an incoming OIDC authorization-code redirect callback. ```swift public struct OMSWalletSession: Equatable, Sendable { - /// Expiration time for the active wallet session, as the ISO-8601 timestamp returned by the - /// wallet API (the same value as `WalletCredential.expiresAt`). + /// The current expiry of the active wallet session, as the ISO-8601 timestamp returned by the + /// wallet API. The server extends it with use up to the requested session lifetime, and the + /// SDK refreshes it, so it can be later than the sign-in `WalletCredential.expiresAt`. public let expiresAt: String /// Auth metadata for the active wallet session. public let auth: OMSWalletSessionAuth diff --git a/MIGRATION.md b/MIGRATION.md index 05d8421..008e164 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -200,6 +200,26 @@ let expiry = formatter.date(from: session.expiresAt) ?? ISO8601DateFormatter().date(from: session.expiresAt) ``` +### Sliding session expiry + +The wallet API now extends a session's expiry while it is used, up to the requested +`sessionLifetimeSeconds`, and ends idle sessions sooner. `OMSWalletSession.expiresAt` is the current +expiry, not a fixed value from sign-in: the SDK refreshes it after restoring a session, while the +session is in use, and before treating a locally expired session as ended. Read +`omsWallet.wallet.session?.expiresAt` again instead of caching the sign-in +`WalletCredential.expiresAt`. + +When the wallet API reports the session's credential expired or gone (WaaS `KeyExpired` 7203 or +`KeyNotFound` 7202), the SDK ends the session as on local expiry, notifies session-expired observers +once, and throws `.sessionExpired` instead of `.requestFailed`. That includes a rejected +transaction `Execute`, which WaaS refuses before running it: it throws `.sessionExpired` instead of +`.transactionExecutionUnconfirmed`. Status polling after a submitted transaction keeps +`.transactionStatusLookupFailed` and its `txnId`. If the wallet API is unreachable when a session reaches its local expiry, operations throw +the network error (`.requestFailed`) and keep the session instead of throwing `.sessionExpired`. + +`WalletClient.maxSessionLifetimeSeconds` rose from 2,592,000 (30 days) to 15,552,000 (180 days). The +default lifetime is unchanged. + ### Networks `Network.polygonAmoy` was renamed to `Network.amoy` (raw value `"amoy"`, chain ID `80002`), and the diff --git a/README.md b/README.md index f045c42..114a5d3 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Pass your OMS publishable key when creating the client. The SDK derives the wall Wallet API requests are signed with a non-extractable Keychain P-256 credential using the `ecdsa-p256-sha256` signing algorithm. The credential remains Keychain-managed and is not serialized into SDK session storage. -Only completed wallet session metadata is restored automatically, including the active wallet (ID, type, address, reference, and key origin), expiry, and auth metadata such as email or OIDC issuer/provider details when available. The SDK checks the cached session expiry before restoring a session. Expired sessions are not activated, and invalid session metadata is cleared; expired session metadata remains in storage as a reauth hint until `signOut()` or a new auth flow clears or replaces it. +Only completed wallet session metadata is restored automatically, including the active wallet (ID, type, address, reference, and key origin), expiry, and auth metadata such as email or OIDC issuer/provider details when available. The SDK checks the cached session expiry before restoring a session, then asks the wallet API for the current expiry in the background. Expired sessions are not activated, and invalid session metadata is cleared; expired session metadata remains in storage as a reauth hint until `signOut()` or a new auth flow clears or replaces it. ## Authentication Flow @@ -129,15 +129,26 @@ if let activeWallet = omsWallet.wallet.activeWallet, `activeWallet` is a `Wallet`, the same type `listWallets()` returns, or `nil` when signed out. `session` holds the expiry and auth metadata and is non-`nil` exactly when `activeWallet` is. Its -`expiresAt` is the ISO-8601 timestamp returned by the wallet API, the same value as -`WalletCredential.expiresAt`. +`expiresAt` is the current expiry as an ISO-8601 timestamp from the wallet API: the server extends +it with use up to the requested session lifetime, and the SDK refreshes it. Pass `sessionLifetimeSeconds` to `startEmailAuth` when you need a shorter or longer email session. The default is `WalletClient.defaultSessionLifetimeSeconds` (one week), and -custom values must be from 1 through `WalletClient.maxSessionLifetimeSeconds` (2,592,000 seconds, -30 days). The SDK validates the value before sending -the one-time code. Use `addSessionExpiredObserver` when your app needs to react -to session expiry: +custom values must be from 1 through `WalletClient.maxSessionLifetimeSeconds` (15,552,000 seconds, +180 days). The SDK validates the value before sending +the one-time code. The lifetime is a ceiling: the wallet API ends an idle session sooner and extends +the expiry while the session is used. + +The SDK refreshes `session.expiresAt` from the wallet API after restoring a session, at least every +six hours while the session is in use, and before it treats a locally expired session as ended. A +session the server has extended continues. When the wallet API rejects a request signed with the +session's credential because that credential expired or is gone (WaaS `KeyExpired` 7203 or +`KeyNotFound` 7202), the SDK ends the session exactly as on local expiry: it notifies +session-expired observers once and throws `.sessionExpired`. If the device is offline when the +expiry passes, the session is kept and rechecked later; operations meanwhile throw the network +error. Wallet API versions without expiry refresh keep the local expiry. Refresh requests never +overlap your own wallet calls, so request nonces stay in order. Use +`addSessionExpiredObserver` when your app needs to react to session expiry: ```swift let sessionExpiredObservation = omsWallet.wallet.addSessionExpiredObserver { event in @@ -266,7 +277,7 @@ Pass `walletSelection` or `sessionLifetimeSeconds` to `startOIDCRedirectAuth` to store completion preferences with the pending redirect state. Values passed to `handleOIDCRedirectCallback` override pending values; otherwise the SDK uses automatic wallet selection and a one-week session lifetime. Custom session -lifetime values must be from 1 through 2,592,000 seconds (30 days). Provider +lifetime values must be from 1 through 15,552,000 seconds (180 days). Provider configs can use `.authCode` to omit PKCE parameters or `.authCodePKCE` for PKCE. Providers with omitted or empty `scopes` omit the OAuth `scope` authorization parameter. diff --git a/Sources/OMSWallet/Clients/SessionCredentialTransport.swift b/Sources/OMSWallet/Clients/SessionCredentialTransport.swift new file mode 100644 index 0000000..2abb69e --- /dev/null +++ b/Sources/OMSWallet/Clients/SessionCredentialTransport.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Orders requests signed with the wallet session's credential. WaaS requires each request's nonce +/// to exceed the last one it accepted, so the SDK's own expiry refresh (`GetCredential`) must not +/// overlap an app request: a refresh starts only once no other signed request is in flight, and +/// signed requests that start while a refresh is pending or in flight wait for it to finish. +final class SignedRequestGate: @unchecked Sendable { + private let lock = NSLock() + private var activeRequests = 0 + private var isRefreshActive = false + private var pendingRefreshes = 0 + /// Blocked acquirers; each is resumed with `false` on a release and then retries. + private var waiters: [CheckedContinuation] = [] + + func acquire(isRefresh: Bool) async { + var isPending = false + while true { + let acquired = await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + let canAcquire = isRefresh + ? !isRefreshActive && activeRequests == 0 + : !isRefreshActive && pendingRefreshes == 0 + if canAcquire { + if isRefresh { + isRefreshActive = true + if isPending { + pendingRefreshes -= 1 + } + } else { + activeRequests += 1 + } + lock.unlock() + continuation.resume(returning: true) + return + } + if isRefresh && !isPending { + isPending = true + pendingRefreshes += 1 + } + waiters.append(continuation) + lock.unlock() + } + if acquired { + return + } + } + } + + func release(isRefresh: Bool) { + lock.lock() + if isRefresh { + isRefreshActive = false + } else { + activeRequests -= 1 + } + let waiters = self.waiters + self.waiters = [] + lock.unlock() + waiters.forEach { $0.resume(returning: false) } + } +} + +/// Wraps the transport of a client that signs with the wallet session's credential (the signed +/// WaaS client and the wallet-import client, never the public client). It applies the +/// `SignedRequestGate` and reports each response to the session, which ends itself when WaaS +/// rejects its credential. +struct SessionCredentialTransport: WebRPCTransport { + let base: any WebRPCTransport + let gate: SignedRequestGate + /// The session revision when a wallet session is active, or `nil` (for example during sign-in). + let activeSessionRevision: @Sendable () -> UInt64? + let onResponse: @Sendable (_ response: WebRPCHTTPResponse, _ path: String, _ revision: UInt64) -> Void + + func post( + baseURL: String, + path: String, + body: Data, + headers: [String: String] + ) async throws -> WebRPCHTTPResponse { + let isRefresh = path == WaasAPI.GetCredential.urlPath + await gate.acquire(isRefresh: isRefresh) + let revision = activeSessionRevision() + let response: WebRPCHTTPResponse + do { + response = try await base.post(baseURL: baseURL, path: path, body: body, headers: headers) + } catch { + gate.release(isRefresh: isRefresh) + throw error + } + gate.release(isRefresh: isRefresh) + if let revision { + onResponse(response, path, revision) + } + return response + } +} diff --git a/Sources/OMSWallet/Clients/WalletAuth.swift b/Sources/OMSWallet/Clients/WalletAuth.swift index eab765a..8d9fbe4 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -11,7 +11,7 @@ extension WalletClient { /// - Parameters: /// - email: The email address to send the one-time passcode to. /// - sessionLifetimeSeconds: The requested credential lifetime. Must be from 1 through - /// 2,592,000 seconds (30 days). + /// 15,552,000 seconds (180 days). public func startEmailAuth( email: String, sessionLifetimeSeconds: UInt32 = WalletClient.defaultSessionLifetimeSeconds @@ -754,8 +754,8 @@ extension WalletClient { /// signer metadata to the keychain. @discardableResult public func useWallet(walletId: String) async throws -> WalletActivationResult { - try await runOMSWalletOperation(.walletUseWallet) { - try requireWalletSelectionOrActiveSession() + try await runSessionOperation(.walletUseWallet) { + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() let activationRevision = sessionRevisionSnapshot() return try await useWallet( @@ -778,8 +778,8 @@ extension WalletClient { walletType: WalletType = WalletType.ethereum, reference: String? = nil ) async throws -> WalletActivationResult { - try await runOMSWalletOperation(.walletCreateWallet) { - try requireWalletSelectionOrActiveSession() + try await runSessionOperation(.walletCreateWallet) { + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() let activationRevision = sessionRevisionSnapshot() return try await createWallet( @@ -793,8 +793,8 @@ extension WalletClient { /// Lists all wallets available to the authenticated credential. public func listWallets() async throws -> [Wallet] { - try await runOMSWalletOperation(.walletListWallets) { - try requireWalletSelectionOrActiveSession() + try await runSessionOperation(.walletListWallets) { + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() return try await listWallets(startingAt: nil) } diff --git a/Sources/OMSWallet/Clients/WalletClient.swift b/Sources/OMSWallet/Clients/WalletClient.swift index bda0afc..a00e6d2 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -20,8 +20,15 @@ public final class WalletClient: @unchecked Sendable { /// The session lifetime used when a sign-in call does not pass `sessionLifetimeSeconds` /// (7 days). public static let defaultSessionLifetimeSeconds: UInt32 = 604_800 - /// The longest session lifetime the wallet API accepts (30 days). - public static let maxSessionLifetimeSeconds: UInt32 = 2_592_000 + /// The longest session lifetime the wallet API accepts (180 days). + public static let maxSessionLifetimeSeconds: UInt32 = 15_552_000 + + /// How long after sign-in or the last credential refresh a successful wallet call triggers a + /// background refresh of the sliding session expiry. + static let credentialRefreshIntervalSeconds: TimeInterval = 6 * 60 * 60 + /// The first and longest delay before the expiry timer rechecks a session it could not refresh. + static let sessionExpiryRetryBaseSeconds: TimeInterval = 60 + static let sessionExpiryRetryMaxSeconds: TimeInterval = 15 * 60 static let defaultTransactionStatusPollTimeoutMs: UInt64 = 60_000 static let defaultFastTransactionStatusPollIntervalMs: UInt64 = 400 @@ -35,11 +42,13 @@ public final class WalletClient: @unchecked Sendable { withSessionLock { _signedClient } } set { - withSessionLock { _signedClient = newValue } + withSessionLock { _signedClient = observingSessionCredential(newValue) } } } var publicClient: WaasPublicClient - let walletImportClient: WaasClient? + private(set) var walletImportClient: WaasClient? + /// Shared by every client that signs with the session credential, so their nonces stay ordered. + let signedRequestGate = SignedRequestGate() let indexerClient: IndexerClient let projectId: String @@ -97,6 +106,14 @@ public final class WalletClient: @unchecked Sendable { } private var _activeWallet: Wallet? var _sessionRevision: UInt64 = 0 + /// The in-flight `getCredential` refresh, shared by concurrent callers of the same session. + var _credentialRefresh: (id: UUID, revision: UInt64, task: Task)? + /// Set once the server reports it has no `GetCredential` method; never retried afterwards. + var _isCredentialRefreshUnsupported = false + /// When the active session's expiry was last taken from the server (sign-in or refresh). + var _lastCredentialRefreshAt: Date? + /// The last session revision ended because WaaS rejected its credential. + var _serverRejectedSessionRevision: UInt64? private var _sessionExpiredObservers: [UUID: SessionExpiredObserver] = [:] private var _verifier = "" private var _challenge = "" @@ -250,6 +267,7 @@ public final class WalletClient: @unchecked Sendable { publishableKey: publishableKey, environment: environment ) + observeSessionCredentialClients() restoreStoredWalletSession(storedWallet) } @@ -290,9 +308,34 @@ public final class WalletClient: @unchecked Sendable { publishableKey: publishableKey, environment: environment ) + observeSessionCredentialClients() restoreStoredWalletSession(storedWallet) } + /// Routes the clients that sign with the session credential through + /// `SessionCredentialTransport`. The public client is never wrapped: its errors (for example + /// `inspectRemoteCredential`'s 7202 for an unknown remote credential) say nothing about the + /// session. + private func observeSessionCredentialClients() { + _signedClient = observingSessionCredential(_signedClient) + walletImportClient = walletImportClient.map(observingSessionCredential) + } + + func observingSessionCredential(_ client: WaasClient) -> WaasClient { + WaasClient( + baseURL: client.baseURL, + transport: SessionCredentialTransport( + base: client.transport, + gate: signedRequestGate, + activeSessionRevision: { [weak self] in self?.activeWalletSessionRevision() }, + onResponse: { [weak self] response, path, revision in + self?.handleSessionCredentialResponse(response, path: path, revision: revision) + } + ), + headers: client.headers + ) + } + @discardableResult public func addSessionExpiredObserver( _ observer: @escaping @MainActor @Sendable (OMSWalletSessionExpiredEvent) -> Void @@ -368,7 +411,8 @@ public final class WalletClient: @unchecked Sendable { } } - func requireWalletSelectionOrActiveSession() throws { + func requireWalletSelectionOrActiveSession() async throws { + try await refreshLocallyExpiredSession() if let notification = expireCurrentSessionIfNeeded() { deliverSessionExpiredNotification(notification) throw OMSWalletError.sessionExpired() @@ -383,7 +427,8 @@ public final class WalletClient: @unchecked Sendable { } } - func requireActiveWalletId() throws -> String { + func requireActiveWalletId() async throws -> String { + try await refreshLocallyExpiredSession() if let notification = expireCurrentSessionIfNeeded() { deliverSessionExpiredNotification(notification) throw OMSWalletError.sessionExpired() @@ -443,6 +488,10 @@ public final class WalletClient: @unchecked Sendable { self._activeWallet = wallet self._sessionExpiresAt = sessionMetadata.expiresAt self._sessionAuth = sessionMetadata.auth + // Sign-in counts as a refresh; a wallet switch keeps the existing refresh time. + if self._lastCredentialRefreshAt == nil { + self._lastCredentialRefreshAt = self.currentDate() + } } if let snapshot = self.currentSessionSnapshot() { self.scheduleSessionExpiry(snapshot) diff --git a/Sources/OMSWallet/Clients/WalletImports.swift b/Sources/OMSWallet/Clients/WalletImports.swift index ef69a9d..5ef3fb7 100644 --- a/Sources/OMSWallet/Clients/WalletImports.swift +++ b/Sources/OMSWallet/Clients/WalletImports.swift @@ -32,8 +32,8 @@ extension WalletClient { privateKey: WalletImportPrivateKey, reference: String? = nil ) async throws -> WalletActivationResult { - try await runOMSWalletOperation(.walletImportWallet) { - let context = try walletImportActivationContext(for: privateKey.walletType) + try await runSessionOperation(.walletImportWallet) { + let context = try await walletImportActivationContext(for: privateKey.walletType) try WalletImportValidation.validateReference(reference) var plaintext = try WalletImportValidation.plaintext(privateKey) defer { plaintext.resetBytes(in: plaintext.indices) } @@ -58,7 +58,7 @@ extension WalletClient { ), reference: reference ) - try requireWalletImportActivationContextStillActive(context) + try await requireWalletImportActivationContextStillActive(context) return try activateImportedWallet(wallet, context: context) } } @@ -67,11 +67,11 @@ extension WalletClient { public func getWalletImportRecipientKey( cipherSuite: WalletImportCipherSuite ) async throws -> WalletImportRecipientKey { - try await runOMSWalletOperation(.walletGetImportRecipientKey) { - try requireWalletSelectionOrActiveSession() + try await runSessionOperation(.walletGetImportRecipientKey) { + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() let recipient = try await getWalletImportRecipientKeyUnchecked(cipherSuite: cipherSuite) - try requireWalletSelectionOrActiveSession() + try await requireWalletSelectionOrActiveSession() return recipient } } @@ -83,15 +83,15 @@ extension WalletClient { keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil ) async throws -> WalletActivationResult { - try await runOMSWalletOperation(.walletImportEncryptedWallet) { - let context = try walletImportActivationContext(for: walletType) + try await runSessionOperation(.walletImportEncryptedWallet) { + let context = try await walletImportActivationContext(for: walletType) try WalletImportValidation.validateReference(reference) let wallet = try await requestImportWallet( walletType: walletType, keyMaterial: keyMaterial, reference: reference ) - try requireWalletImportActivationContextStillActive(context) + try await requireWalletImportActivationContextStillActive(context) return try activateImportedWallet(wallet, context: context) } } @@ -153,7 +153,7 @@ extension WalletClient { private func walletImportActivationContext( for walletType: WalletType - ) throws -> WalletImportActivationContext { + ) async throws -> WalletImportActivationContext { if let pending = activePendingWalletSelection { try requireActivePendingWalletSelection(pending) guard pending.walletType == walletType else { @@ -165,10 +165,10 @@ extension WalletClient { return .pending(session: pending, revision: sessionRevisionSnapshot()) } - try requireWalletSelectionOrActiveSession() + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() return .active( - walletId: try requireActiveWalletId(), + walletId: try await requireActiveWalletId(), metadata: try currentSessionMetadata(), revision: sessionRevisionSnapshot() ) @@ -176,11 +176,11 @@ extension WalletClient { private func requireWalletImportActivationContextStillActive( _ context: WalletImportActivationContext - ) throws { + ) async throws { try requireCurrentSessionRevision(context.revision) switch context { case .active(let walletId, _, _): - guard try requireActiveWalletId() == walletId else { + guard try await requireActiveWalletId() == walletId else { throw OMSWalletError(code: .sessionMissing, message: "Active wallet session changed") } case .pending(let session, _): diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 468439d..ded1af6 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -9,9 +9,9 @@ extension WalletClient { /// - message: The plaintext message to sign. /// - Returns: A hex-encoded signature string. public func signMessage(network: Network, message: String) async throws -> String { - try await runOMSWalletOperation(.walletSignMessage) { - try requireActiveEthereumWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSignMessage) { + try await requireActiveEthereumWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let params = SignMessageRequest( network: network.waasChainId, @@ -25,9 +25,9 @@ extension WalletClient { } public func signSolanaMessage(message: String) async throws -> String { - try await runOMSWalletOperation(.walletSignSolanaMessage) { - try requireActiveSolanaWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSignSolanaMessage) { + try await requireActiveSolanaWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() return try await signedClient.signMessage( SignMessageRequest(network: "", walletId: walletId, message: message) @@ -36,9 +36,9 @@ extension WalletClient { } public func signTypedData(network: Network, typedData: JSONValue) async throws -> String { - try await runOMSWalletOperation(.walletSignTypedData) { - try requireActiveEthereumWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSignTypedData) { + try await requireActiveEthereumWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let params = SignTypedDataRequest( network: network.waasChainId, @@ -58,7 +58,7 @@ extension WalletClient { signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidMessageSignature) { - let walletAddress = try verificationWalletAddress(walletAddress, walletType: .ethereum) + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .ethereum) let response = try await publicClient.isValidMessageSignature( IsValidMessageSignatureRequest( network: network.waasChainId, @@ -79,7 +79,7 @@ extension WalletClient { signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidSolanaMessageSignature) { - let walletAddress = try verificationWalletAddress(walletAddress, walletType: .solana) + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .solana) let response = try await publicClient.isValidMessageSignature( IsValidMessageSignatureRequest( networkFamily: .solana, @@ -99,7 +99,7 @@ extension WalletClient { signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidTypedDataSignature) { - let walletAddress = try verificationWalletAddress(walletAddress, walletType: .ethereum) + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .ethereum) let response = try await publicClient.isValidTypedDataSignature( IsValidTypedDataSignatureRequest( network: network.waasChainId, @@ -115,9 +115,9 @@ extension WalletClient { } public func signTronMessage(message: String) async throws -> String { - try await runOMSWalletOperation(.walletSignTronMessage) { - try requireActiveTronWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSignTronMessage) { + try await requireActiveTronWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() return try await signedClient.signMessage( SignMessageRequest(network: "", walletId: walletId, message: message) @@ -126,9 +126,9 @@ extension WalletClient { } public func signTronTypedData(typedData: JSONValue) async throws -> String { - try await runOMSWalletOperation(.walletSignTronTypedData) { - try requireActiveTronWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSignTronTypedData) { + try await requireActiveTronWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() return try await signedClient.signTypedData( SignTypedDataRequest(network: "", walletId: walletId, typedData: typedData.waasValue) @@ -142,7 +142,7 @@ extension WalletClient { signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidTronMessageSignature) { - let walletAddress = try verificationWalletAddress(walletAddress, walletType: .tron) + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .tron) return try await publicClient.isValidMessageSignature( IsValidMessageSignatureRequest( networkFamily: .tron, @@ -160,7 +160,7 @@ extension WalletClient { signature: String ) async throws -> Bool { try await runOMSWalletOperation(.walletIsValidTronTypedDataSignature) { - let walletAddress = try verificationWalletAddress(walletAddress, walletType: .tron) + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .tron) return try await publicClient.isValidTypedDataSignature( IsValidTypedDataSignatureRequest( networkFamily: .tron, @@ -181,9 +181,9 @@ extension WalletClient { waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { - try await runOMSWalletOperation(.walletSendTransaction) { - try requireActiveEthereumWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSendTransaction) { + try await requireActiveEthereumWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) return try await sendTransaction( @@ -210,9 +210,9 @@ extension WalletClient { waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { - try await runOMSWalletOperation(.walletSendTransaction) { - try requireActiveEthereumWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSendTransaction) { + try await requireActiveEthereumWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) return try await sendTransaction( @@ -237,9 +237,9 @@ extension WalletClient { waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { - try await runOMSWalletOperation(.walletSendSolanaTransfer) { - try requireActiveSolanaWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSendSolanaTransfer) { + try await requireActiveSolanaWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) let prepared = try await signedClient.prepareSolanaTransfer( @@ -272,9 +272,9 @@ extension WalletClient { waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { - try await runOMSWalletOperation(.walletSendTronTransaction) { - try requireActiveTronWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletSendTronTransaction) { + try await requireActiveTronWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) // `data` is forwarded as given: nil omits the field (a TRX transfer), while any value, @@ -309,9 +309,9 @@ extension WalletClient { waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { - try await runOMSWalletOperation(.walletCallTronContract) { - try requireActiveTronWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletCallTronContract) { + try await requireActiveTronWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() try requireContractMethodName(method) let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) @@ -376,9 +376,9 @@ extension WalletClient { waitForStatus: Bool = true, statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { - try await runOMSWalletOperation(.walletCallContract) { - try requireActiveEthereumWallet() - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletCallContract) { + try await requireActiveEthereumWallet() + let walletId = try await requireActiveWalletId() try requireActiveCredential() try requireContractMethodName(method) let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) @@ -409,8 +409,8 @@ extension WalletClient { /// - Parameter txnId: The transaction ID returned by the wallet API prepare/execute flow. /// - Returns: The current transaction status and transaction hash when available. public func getTransactionStatus(txnId: String) async throws -> TransactionStatusResponse { - try await runOMSWalletOperation(.walletGetTransactionStatus) { - _ = try requireActiveWalletId() + try await runSessionOperation(.walletGetTransactionStatus) { + _ = try await requireActiveWalletId() try requireActiveCredential() return try await signedClient.transactionStatus( TransactionStatusRequest(txnId: txnId) @@ -781,35 +781,35 @@ extension WalletClient { return !txnHash.isEmpty } - private func requireActiveEthereumWallet() throws { - try requireActiveWalletType(.ethereum) + private func requireActiveEthereumWallet() async throws { + try await requireActiveWalletType(.ethereum) } - private func requireActiveSolanaWallet() throws { - try requireActiveWalletType(.solana) + private func requireActiveSolanaWallet() async throws { + try await requireActiveWalletType(.solana) } - private func requireActiveTronWallet() throws { - try requireActiveWalletType(.tron) + private func requireActiveTronWallet() async throws { + try await requireActiveWalletType(.tron) } /// Returns `walletAddress` when given (it must not be blank); otherwise the active wallet's address, which must be of /// `walletType`. Verification never sends a wallet ID. - private func verificationWalletAddress(_ walletAddress: String?, walletType: WalletType) throws -> String { + private func verificationWalletAddress(_ walletAddress: String?, walletType: WalletType) async throws -> String { if let walletAddress { guard !walletAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw OMSWalletError(code: .validationError, message: "walletAddress must not be empty") } return walletAddress } - return try requireActiveWalletType(walletType).address + return try await requireActiveWalletType(walletType).address } /// Checks the session (expiring it if due) before the stored wallet type; the address shape is /// not checked. @discardableResult - func requireActiveWalletType(_ type: WalletType) throws -> Wallet { - _ = try requireActiveWalletId() + func requireActiveWalletType(_ type: WalletType) async throws -> Wallet { + _ = try await requireActiveWalletId() guard let activeWallet else { throw OMSWalletError.sessionMissing() } diff --git a/Sources/OMSWallet/Clients/WalletSessions.swift b/Sources/OMSWallet/Clients/WalletSessions.swift index e232cfa..d5426b6 100644 --- a/Sources/OMSWallet/Clients/WalletSessions.swift +++ b/Sources/OMSWallet/Clients/WalletSessions.swift @@ -1,5 +1,20 @@ import Foundation +/// The result of asking WaaS for the active credential's current (sliding) expiry. +enum CredentialRefreshOutcome: @unchecked Sendable { + /// The session expiry now matches the server's and lies in the future. + case refreshed + /// The server reported the credential gone or expired, or its expiry has already passed; the + /// session has been ended. + case ended + /// The server has no `GetCredential` method, so the local expiry stays authoritative. + case unsupported + /// The refresh failed for another reason (for example a network error); the session is kept. + case unavailable(any Error) + /// There was no session, or the session changed while the refresh was in flight. + case superseded +} + @available(macOS 12.0, iOS 15.0, *) extension WalletClient { /// Expiry and auth metadata for the active wallet session. Non-`nil` exactly when @@ -37,6 +52,10 @@ extension WalletClient { sessionAuth = restoredWallet.auth if let snapshot = currentSessionSnapshot() { scheduleSessionExpiry(snapshot) + // The stored expiry may be stale: the server slides it forward with use. + Task { [weak self] in + _ = await self?.refreshCredentialExpiry() + } } } @@ -94,6 +113,7 @@ extension WalletClient { private func clearActiveSessionForExpiryLocked() { _sessionRevision += 1 + _lastCredentialRefreshAt = nil sessionExpiryTask?.cancel() sessionExpiryTask = nil activePendingWalletSelection = nil @@ -132,23 +152,25 @@ extension WalletClient { } } - func scheduleSessionExpiry(_ session: WalletSessionSnapshot) { - let delay = max(0, session.expiryDate.timeIntervalSince(currentDate())) - guard delay > 0 else { - expireSessionFromTimer(session) - return - } + /// Schedules the expiry timer for `session`: at its expiry, or, for `retryAttempt` > 0, after + /// a backed-off delay to recheck a session whose expiry could not be refreshed. + func scheduleSessionExpiry(_ session: WalletSessionSnapshot, retryAttempt: Int = 0) { + let delay = retryAttempt > 0 + ? Self.sessionExpiryRetryDelay(attempt: retryAttempt) + : max(0, session.expiryDate.timeIntervalSince(currentDate())) let delayNanoseconds = delay * 1_000_000_000 let cappedNanoseconds = delayNanoseconds.isFinite ? min(delayNanoseconds, Double(UInt64.max - 1)) : Double(UInt64.max - 1) let nanoseconds = UInt64(cappedNanoseconds) let task = Task { [weak self] in - try? await Task.sleep(nanoseconds: nanoseconds) + if nanoseconds > 0 { + try? await Task.sleep(nanoseconds: nanoseconds) + } guard !Task.isCancelled else { return } - self?.expireSessionFromTimer(session) + await self?.handleSessionExpiryTimer(session, retryAttempt: retryAttempt) } let shouldCancelTask = withSessionLock { () -> Bool in guard isCurrentSessionSnapshotLocked(session) else { @@ -163,24 +185,273 @@ extension WalletClient { } } - private func expireSessionFromTimer(_ session: WalletSessionSnapshot) { - let transition = withSessionLock { () -> ( - notification: SessionExpiredNotification?, - reschedule: WalletSessionSnapshot? - ) in + static func sessionExpiryRetryDelay(attempt: Int) -> TimeInterval { + let exponent = Double(min(max(attempt - 1, 0), 16)) + return min(sessionExpiryRetryBaseSeconds * pow(2, exponent), sessionExpiryRetryMaxSeconds) + } + + /// At local expiry, asks the server for the current expiry first: a session the server has + /// extended is rescheduled, and one it cannot vouch for because it is offline is rechecked + /// later instead of being ended. + private func handleSessionExpiryTimer(_ session: WalletSessionSnapshot, retryAttempt: Int) async { + let isDue = withSessionLock { () -> Bool? in guard isCurrentSessionSnapshotLocked(session) else { - return (nil, nil) + return nil + } + return isSessionExpired(session) + } + guard let isDue else { + return + } + guard isDue else { + scheduleSessionExpiry(session) + return + } + switch await refreshCredentialExpiry() { + case .refreshed, .ended, .superseded: + return + case .unsupported: + expireSessionFromTimer(session) + case .unavailable: + scheduleSessionExpiry(session, retryAttempt: retryAttempt + 1) + } + } + + private func expireSessionFromTimer(_ session: WalletSessionSnapshot) { + deliverSessionExpiredNotification( + withSessionLock { () -> SessionExpiredNotification? in + guard isCurrentSessionSnapshotLocked(session), isSessionExpired(session) else { + return nil + } + clearActiveSessionForExpiryLocked() + return makeSessionExpiredNotificationLocked(session) } - guard isSessionExpired(session) else { - return (nil, session) + ) + } + + /// Before an operation uses a locally expired session, asks the server whether it extended + /// the expiry. An extended session continues; an ended session throws `sessionExpired`; an + /// unreachable server throws its error and keeps the session. Without server support the + /// caller's local expiry check applies as before. + func refreshLocallyExpiredSession() async throws { + guard let session = currentSessionSnapshot(), isSessionExpired(session) else { + return + } + switch await refreshCredentialExpiry() { + case .refreshed, .unsupported, .superseded: + return + case .ended: + throw OMSWalletError.sessionExpired() + case .unavailable(let error): + throw error + } + } + + /// Fetches the active credential's current expiry with `getCredential` and applies it to the + /// session. Concurrent callers for the same session share one request, and a result is + /// applied only while the session it was requested for is still current. + func refreshCredentialExpiry() async -> CredentialRefreshOutcome { + enum Start { + case task(Task) + case outcome(CredentialRefreshOutcome) + } + let start = withSessionLock { () -> Start in + if let inFlight = _credentialRefresh, inFlight.revision == _sessionRevision { + return .task(inFlight.task) } - clearActiveSessionForExpiryLocked() - return (makeSessionExpiredNotificationLocked(session), nil) + guard !_isCredentialRefreshUnsupported else { + return .outcome(.unsupported) + } + guard currentSessionLocked() != nil else { + return .outcome(.superseded) + } + let id = UUID() + let revision = _sessionRevision + let client = signedClient + let task = Task { [weak self] () -> CredentialRefreshOutcome in + let outcome: CredentialRefreshOutcome + do { + let response = try await client.getCredential() + outcome = self?.applyRefreshedCredentialExpiry( + response.credential.expiresAt, + revision: revision + ) ?? .superseded + } catch { + outcome = self?.handleCredentialRefreshFailure(error, revision: revision) ?? .superseded + } + self?.finishCredentialRefresh(id) + return outcome + } + _credentialRefresh = (id, revision, task) + return .task(task) + } + switch start { + case .task(let task): + return await task.value + case .outcome(let outcome): + return outcome + } + } + + private func finishCredentialRefresh(_ id: UUID) { + withSessionLock { + if _credentialRefresh?.id == id { + _credentialRefresh = nil + } + } + } + + private func applyRefreshedCredentialExpiry( + _ expiresAt: String, + revision: UInt64 + ) -> CredentialRefreshOutcome { + guard OMSWalletSession.parseDate(expiresAt) != nil else { + return .unavailable( + OMSWalletError(code: .invalidResponse, message: "Credential response has an invalid expiresAt") + ) + } + enum Applied { + case superseded + case extended(WalletSessionSnapshot) + case expired(SessionExpiredNotification) + } + let applied = withSessionLock { () -> Applied in + guard _sessionRevision == revision, + let wallet = activeWallet, + let auth = sessionAuth, + let session = WalletSessionSnapshot(wallet: wallet, expiresAt: expiresAt, auth: auth) else { + return .superseded + } + // The in-memory session follows the server even if the keychain write fails; a stale + // stored expiry only means the next launch refreshes again. + try? credentialSession.persist(wallet: wallet, expiresAt: expiresAt, auth: auth) + sessionExpiresAt = expiresAt + _lastCredentialRefreshAt = currentDate() + guard !isSessionExpired(session) else { + // The server's expiry has already passed by the local clock; end it as local + // expiry does rather than refreshing again. + clearActiveSessionForExpiryLocked() + return .expired(makeSessionExpiredNotificationLocked(session)) + } + return .extended(session) + } + switch applied { + case .superseded: + return .superseded + case .extended(let session): + scheduleSessionExpiry(session) + return .refreshed + case .expired(let notification): + deliverSessionExpiredNotification(notification) + return .ended + } + } + + private func handleCredentialRefreshFailure( + _ error: any Error, + revision: UInt64 + ) -> CredentialRefreshOutcome { + if isCredentialRejectedByServer(error) { + // `SessionCredentialTransport` has normally ended the session already. + endSessionRejectedByServer(revision: revision) + return wasSessionRejectedByServer(revision) ? .ended : .superseded + } + if let webRPCError = error as? WebRPCError, + webRPCError.kind == .webrpcBadRoute || webRPCError.error == "WebrpcBadRoute" { + withSessionLock { _isCredentialRefreshUnsupported = true } + return .unsupported + } + return .unavailable(error) + } + + /// Starts a background expiry refresh when the last one (or sign-in) is at least + /// `credentialRefreshIntervalSeconds` old. + func refreshCredentialExpiryInBackgroundIfStale() { + let isStale = withSessionLock { () -> Bool in + guard !_isCredentialRefreshUnsupported, currentSessionLocked() != nil else { + return false + } + guard let lastRefresh = _lastCredentialRefreshAt else { + return true + } + return currentDate().timeIntervalSince(lastRefresh) >= Self.credentialRefreshIntervalSeconds + } + guard isStale else { + return } - if let reschedule = transition.reschedule { - scheduleSessionExpiry(reschedule) + Task { [weak self] in + _ = await self?.refreshCredentialExpiry() + } + } + + /// The session revision while a wallet session is active, or `nil` (for example during + /// sign-in, before a wallet is activated). + func activeWalletSessionRevision() -> UInt64? { + withSessionLock { currentSessionLocked() == nil ? nil : _sessionRevision } + } + + /// Called by `SessionCredentialTransport` for every response to a request signed with the + /// session credential while `revision` was active. + func handleSessionCredentialResponse(_ response: WebRPCHTTPResponse, path: String, revision: UInt64) { + guard (200...299).contains(response.statusCode) else { + let error = WaasGenerated.decodeWebRPCError(statusCode: response.statusCode, data: response.body) + if isCredentialRejectedByServer(error) { + endSessionRejectedByServer(revision: revision) + } + return + } + if path != WaasAPI.GetCredential.urlPath { + refreshCredentialExpiryInBackgroundIfStale() + } + } + + /// Ends the session as local expiry does when the server reported its credential gone or + /// expired, unless a newer sign-in or sign-out already replaced it. + func endSessionRejectedByServer(revision: UInt64) { + let notification = withSessionLock { () -> SessionExpiredNotification? in + guard _sessionRevision == revision, let session = currentSessionLocked() else { + return nil + } + clearActiveSessionForExpiryLocked() + _serverRejectedSessionRevision = revision + return makeSessionExpiredNotificationLocked(session) + } + deliverSessionExpiredNotification(notification) + } + + func wasSessionRejectedByServer(_ revision: UInt64) -> Bool { + withSessionLock { _serverRejectedSessionRevision == revision } + } + + /// Runs an operation that uses the active wallet session's credential. When WaaS rejected that + /// credential as gone (7202) or expired (7203), `SessionCredentialTransport` has ended the + /// session, and the operation throws `sessionExpired`. + func runSessionOperation( + _ operation: OMSWalletOperation, + _ body: () async throws -> T + ) async throws -> T { + let sessionRevision = activeWalletSessionRevision() + do { + return try await runOMSWalletOperation(operation, body) + } catch let error as OMSWalletError { + guard let sessionRevision, + isCredentialRejectedByServer(error), + wasSessionRejectedByServer(sessionRevision) else { + throw error + } + // WaaS rejects the credential before Execute runs, so a rejected Execute sent nothing + // and is reported as session expired. Status polling after a submitted transaction + // keeps its txnId; observers still learn that the session ended. + if error.code == .transactionStatusLookupFailed { + throw error + } + throw OMSWalletError( + code: .sessionExpired, + message: OMSWalletError.sessionExpired().localizedDescription, + operation: operation, + underlyingError: error + ) } - deliverSessionExpiredNotification(transition.notification) } private func isCurrentSessionSnapshotLocked(_ session: WalletSessionSnapshot) -> Bool { @@ -232,6 +503,7 @@ extension WalletClient { return false } self._sessionRevision += 1 + self._lastCredentialRefreshAt = nil self.latestSessionExpiredEvent = nil self.sessionExpiryTask?.cancel() self.sessionExpiryTask = nil @@ -295,10 +567,10 @@ extension WalletClient { expiresAt: String, sessionId: String? = nil ) async throws -> AuthorizedRemoteAccess { - try await runOMSWalletOperation(.walletAuthorizeRemoteAccess) { - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletAuthorizeRemoteAccess) { + let walletId = try await requireActiveWalletId() try requireActiveCredential() - try requireActiveWalletType(.ethereum) + try await requireActiveWalletType(.ethereum) try validateSmartSessionGrants(grants) let response = try await signedClient.authorizeRemoteAccess( AuthorizeRemoteAccessRequest( @@ -310,7 +582,7 @@ extension WalletClient { sessionId: sessionId ) ) - try requireSameActiveWalletSession(walletId) + try await requireSameActiveWalletSession(walletId) return AuthorizedRemoteAccess( walletId: walletId, sessionId: response.sessionId, @@ -344,8 +616,8 @@ extension WalletClient { cursor: String? = nil, type: AccessGrantType? = nil ) async throws -> AccessGrantPage { - try await runOMSWalletOperation(.walletListAccessPage) { - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletListAccessPage) { + let walletId = try await requireActiveWalletId() try requireActiveCredential() let response = try await signedClient.listAccess( ListAccessRequest( @@ -366,14 +638,14 @@ extension WalletClient { /// Returns one owner-visible remote session and verifies that it belongs to the selected wallet. public func getRemoteAccessSession(sessionId: String) async throws -> RemoteAccessSession { - try await runOMSWalletOperation(.walletGetRemoteAccessSession) { + try await runSessionOperation(.walletGetRemoteAccessSession) { guard !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw OMSWalletError(code: .validationError, message: "sessionId is required") } - let walletId = try requireActiveWalletId() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let response = try await signedClient.getSession(GetSessionRequest(sessionId: sessionId)) - try requireSameActiveWalletSession(walletId) + try await requireSameActiveWalletSession(walletId) guard let session = response.session.sdkValue, session.walletId == walletId else { throw DecodingError.dataCorrupted( DecodingError.Context(codingPath: [], debugDescription: "Session does not belong to the active wallet") @@ -388,16 +660,16 @@ extension WalletClient { sessionId: String, network: Network ) async throws -> [SmartSessionGrantUsage] { - try await runOMSWalletOperation(.walletGetRemoteAccessSessionUsage) { + try await runSessionOperation(.walletGetRemoteAccessSessionUsage) { guard !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw OMSWalletError(code: .validationError, message: "sessionId is required") } - let walletId = try requireActiveWalletId() + let walletId = try await requireActiveWalletId() try requireActiveCredential() let response = try await signedClient.getSessionUsage( GetSessionUsageRequest(sessionId: sessionId, network: network.waasChainId) ) - try requireSameActiveWalletSession(walletId) + try await requireSameActiveWalletSession(walletId) return try response.entries.map { entry in guard let grant = entry.grant.sdkValue, entry.used.map(isCanonicalUnsignedDecimal) ?? true else { @@ -411,8 +683,8 @@ extension WalletClient { } public func getIdToken(ttlSeconds: UInt32? = nil, customClaims: [String: JSONValue]? = nil) async throws -> String { - try await runOMSWalletOperation(.walletGetIdToken) { - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletGetIdToken) { + let walletId = try await requireActiveWalletId() try requireActiveCredential() let params = GetIDTokenRequest( walletId: walletId, @@ -434,8 +706,8 @@ extension WalletClient { /// - Parameter sessionId: The required session identifier for a remote grant, or `nil` for a /// direct grant. public func revokeAccess(credentialId: String, sessionId: String? = nil) async throws { - try await runOMSWalletOperation(.walletRevokeAccess) { - let walletId = try requireActiveWalletId() + try await runSessionOperation(.walletRevokeAccess) { + let walletId = try await requireActiveWalletId() try requireActiveCredential() let params = RevokeAccessRequest( targetCredentialId: credentialId, @@ -455,8 +727,8 @@ extension WalletClient { return Page(limit: pageSize, cursor: cursor) } - private func requireSameActiveWalletSession(_ expectedWalletId: String) throws { - guard try requireActiveWalletId() == expectedWalletId else { + private func requireSameActiveWalletSession(_ expectedWalletId: String) async throws { + guard try await requireActiveWalletId() == expectedWalletId else { throw OMSWalletError(code: .sessionMissing, message: "Active wallet session changed") } try requireActiveCredential() @@ -553,3 +825,18 @@ public struct ListAccessPages: AsyncSequence { } } } + +/// Whether WaaS rejected the request's credential as not found (7202) or expired (7203). +/// `Unauthorized` (7207) is deliberately excluded: it is not specific to the credential. +func isCredentialRejectedByServer(_ error: any Error) -> Bool { + let rejectedCodes = [WebRPCErrorKind.keyNotFound.code, WebRPCErrorKind.keyExpired.code] + if let webRPCError = error as? WebRPCError { + return rejectedCodes.contains(webRPCError.code) + } + guard let upstreamError = (error as? OMSWalletError)?.upstreamError, + upstreamError.service == .waas, + let code = upstreamError.code.flatMap(Int.init) else { + return false + } + return rejectedCodes.contains(code) +} diff --git a/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift index 4a4f66b..f3e600b 100644 --- a/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift +++ b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift @@ -3,8 +3,9 @@ import Foundation /// Expiry and auth metadata for the active wallet session. `WalletClient.session` is non-`nil` /// exactly when `WalletClient.activeWallet` is. public struct OMSWalletSession: Equatable, Sendable { - /// Expiration time for the active wallet session, as the ISO-8601 timestamp returned by the - /// wallet API (the same value as `WalletCredential.expiresAt`). + /// The current expiry of the active wallet session, as the ISO-8601 timestamp returned by the + /// wallet API. The server extends it with use up to the requested session lifetime, and the + /// SDK refreshes it, so it can be later than the sign-in `WalletCredential.expiresAt`. public let expiresAt: String /// Auth metadata for the active wallet session. diff --git a/Sources/OMSWallet/Models/WalletModels.swift b/Sources/OMSWallet/Models/WalletModels.swift index 1df094c..4169f0c 100644 --- a/Sources/OMSWallet/Models/WalletModels.swift +++ b/Sources/OMSWallet/Models/WalletModels.swift @@ -275,6 +275,9 @@ public struct AbiArg: Codable, Equatable, Sendable { public struct WalletCredential: Codable, Equatable, Sendable { public let credentialId: String + /// The credential's current expiry when it was returned, as an ISO-8601 timestamp. The server + /// extends it with use up to the requested session lifetime, and the SDK refreshes the active + /// session's copy in `OMSWalletSession.expiresAt`. public let expiresAt: String public let isCaller: Bool diff --git a/Tests/OMSWalletTests/CredentialExpiryRefreshTests.swift b/Tests/OMSWalletTests/CredentialExpiryRefreshTests.swift new file mode 100644 index 0000000..6b69a06 --- /dev/null +++ b/Tests/OMSWalletTests/CredentialExpiryRefreshTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing +@testable import OMSWallet + +private func isoTimestamp(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) +} + +private func credentialResponse(expiresAt: String) -> WaasAPI.GetCredential.Response { + WaasAPI.GetCredential.Response( + credential: WaasCredentialInfo( + credentialId: "0xmock-credential", + type: .direct, + expiresAt: expiresAt, + isCaller: true + ) + ) +} + +@MainActor +private func waitUntil(attempts: Int = 40, _ condition: () -> Bool) async throws { + for _ in 0.. String = OIDCRedirectAuth.generateNonce, currentDate: @escaping () -> Date = Date.init, storedCredentials: StorableCredentials? = nil, - walletImportClient: WaasClient? = nil + walletImportClient: WaasClient? = nil, + prepareTransport: (MockWaasTransport) throws -> Void = { _ in } ) -> MockWalletClientFixture { let transport = MockWaasTransport() + // Restoring a stored session starts a credential refresh, so its responses go in first. + try? prepareTransport(transport) let indexerBackend = MockIndexerBackend() let indexerClient = indexerBackend.makeClient( publishableKey: "test-publishable-key", @@ -3745,6 +3752,7 @@ final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { private let lock = NSLock() private var responses: [String: [MockWaasResponse]] = [:] private var recordedRequests: [RecordedWebRPCRequest] = [] + private var gates: [String: MockResponseGate] = [:] func enqueue(_ response: T, for path: String) throws { let body = try WebRPCJSON.makeEncoder().encode(response) @@ -3779,6 +3787,30 @@ final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { responses[path, default: []].append(.httpError(statusCode: statusCode, body: body)) } + /// Queues the `WebrpcBadRoute` error a server returns for a method it does not have. + func enqueueWebRPCBadRoute(for path: String) { + enqueueRawHTTPError( + statusCode: 404, + body: Data(#"{"error":"WebrpcBadRoute","code":-2,"msg":"bad route","status":404}"#.utf8), + for: path + ) + } + + private func gate(for path: String) -> MockResponseGate? { + lock.lock() + defer { lock.unlock() } + return gates[path] + } + + /// Holds every response for `path` until the returned gate opens. + func holdResponses(for path: String) -> MockResponseGate { + let gate = MockResponseGate() + lock.lock() + defer { lock.unlock() } + gates[path] = gate + return gate + } + func enqueueTransportError( _ error: WebRPCTransportError, for path: String @@ -3804,6 +3836,7 @@ final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { path: path, body: body ) + await gate(for: path)?.wait() switch response { case .success(let body): @@ -3886,3 +3919,31 @@ final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { return recordedRequests.filter { $0.path == path } } } + +final class MockResponseGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if isOpen { + lock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + lock.unlock() + } + } + } + + func open() { + lock.lock() + isOpen = true + let waiters = self.waiters + self.waiters = [] + lock.unlock() + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/OMSWalletTests/PublicErrorContractsTests.swift b/Tests/OMSWalletTests/PublicErrorContractsTests.swift index 29847d5..c19bd9e 100644 --- a/Tests/OMSWalletTests/PublicErrorContractsTests.swift +++ b/Tests/OMSWalletTests/PublicErrorContractsTests.swift @@ -245,7 +245,7 @@ import Testing equals: error( code: .validationError, operation: .walletStartEmailAuth, - message: "sessionLifetimeSeconds must be an integer between 1 and 2592000" + message: "sessionLifetimeSeconds must be an integer between 1 and 15552000" ) ) @@ -370,6 +370,7 @@ import Testing expiredFixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") expiredFixture.client.sessionExpiresAt = "2025-01-01T00:00:00Z" expiredFixture.client.sessionAuth = .email(OMSWalletEmailSessionAuth(email: "user@example.com")) + expiredFixture.transport.enqueueWebRPCBadRoute(for: WaasAPI.GetCredential.urlPath) await expectPublicError( try await expiredFixture.client.signMessage(network: .polygon, message: "hello"), @@ -393,6 +394,103 @@ import Testing ) } +@Test(arguments: [ + (statusCode: 404, name: "KeyNotFound", code: 7202), + (statusCode: 400, name: "KeyExpired", code: 7203) +]) +func TestPublicErrorContractsServerRejectedCredentialEndsSessionOnce( + statusCode: Int, + name: String, + code: Int +) async throws { + let fixture = makeRestoredWalletClient() + for _ in 0..<2 { + fixture.transport.enqueueRawHTTPError( + statusCode: statusCode, + body: Data( + """ + {"error":"\(name)","code":\(code),"msg":"credential rejected","status":\(statusCode)} + """.utf8 + ), + for: WaasAPI.SignMessage.urlPath + ) + } + let expiredEvents = LockedBox<[OMSWalletSessionExpiredEvent]>([]) + let observation = fixture.client.addSessionExpiredObserver { event in + expiredEvents.withValue { $0.append(event) } + } + defer { observation.cancel() } + + // Two calls are rejected while both are in flight; the session ends only once. + let gate = fixture.transport.holdResponses(for: WaasAPI.SignMessage.urlPath) + let client = fixture.client + let first = Task { try await client.signMessage(network: .polygon, message: "first") } + let second = Task { try await client.signMessage(network: .polygon, message: "second") } + for _ in 0..<40 where fixture.transport.requestCount(for: WaasAPI.SignMessage.urlPath) < 2 { + try await Task.sleep(nanoseconds: 10_000_000) + } + gate.open() + + for task in [first, second] { + await expectPublicError( + try await task.value, + equals: error( + code: .sessionExpired, + operation: .walletSignMessage, + message: "No active credential." + ) + ) + } + for _ in 0..<10 where expiredEvents.withValue({ $0.isEmpty }) { + try await Task.sleep(nanoseconds: 20_000_000) + } + try await Task.sleep(nanoseconds: 50_000_000) + + let events = expiredEvents.withValue { $0 } + #expect(fixture.transport.requestCount(for: WaasAPI.SignMessage.urlPath) == 2) + #expect(events.count == 1) + #expect(events.first?.wallet?.id == "wallet-main") + #expect(fixture.client.session == nil) + #expect(fixture.signer.clearCallCount == 1) +} + +@Test func TestPublicErrorContractsPublicKeyNotFoundKeepsSession() async throws { + // WaasPublic.InspectCredential reports an unknown remote credential as 7202; that is about the + // remote credential, not the signed-in session. + let fixture = makeRestoredWalletClient() + fixture.transport.enqueueRawHTTPError( + statusCode: 404, + body: Data( + """ + {"error":"KeyNotFound","code":7202,"msg":"credential not found","status":404} + """.utf8 + ), + for: WaasPublicAPI.InspectCredential.urlPath + ) + + await expectPublicError( + try await fixture.client.inspectRemoteCredential(credentialId: "unknown-remote-credential"), + equals: error( + code: .requestFailed, + operation: .walletInspectRemoteCredential, + message: "credential not found", + status: 404, + retryable: false, + upstreamError: upstream( + service: .waas, + name: "KeyNotFound", + code: "7202", + message: "credential not found", + status: 404 + ) + ) + ) + + #expect(fixture.client.session != nil) + #expect(fixture.client.activeWallet?.id == "wallet-main") + #expect(fixture.signer.clearCallCount == 0) +} + private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Sendable { override class func canInit(with request: URLRequest) -> Bool { true @@ -473,7 +571,7 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda equals: error( code: .validationError, operation: .walletHandleOIDCRedirectCallback, - message: "sessionLifetimeSeconds must be an integer between 1 and 2592000" + message: "sessionLifetimeSeconds must be an integer between 1 and 15552000" ) ) @@ -701,6 +799,66 @@ private final class MissingAttestationURLProtocol: URLProtocol, @unchecked Senda ) } +@Test func TestPublicErrorContractsServerRejectedCredentialDuringTransactionFlow() async throws { + let keyExpired = Data(#"{"error":"KeyExpired","code":7203,"msg":"key expired","status":400}"#.utf8) + let request = SendTransactionRequest(to: "0x1111111111111111111111111111111111111111", value: "0") + func preparedFixture(txnId: String) throws -> MockWalletClientFixture { + let fixture = makeRestoredWalletClient() + try fixture.transport.enqueue( + PrepareResponse( + txnId: txnId, + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasAPI.PrepareEthereumTransaction.urlPath + ) + return fixture + } + + // The credential middleware rejects Execute before its handler runs, so nothing was sent. + let executeFixture = try preparedFixture(txnId: "txn-execute-expired") + executeFixture.transport.enqueueRawHTTPError(statusCode: 400, body: keyExpired, for: WaasAPI.Execute.urlPath) + await expectPublicError( + try await executeFixture.client.sendTransaction(network: .polygon, request: request), + equals: error( + code: .sessionExpired, + operation: .walletSendTransaction, + message: "No active credential." + ) + ) + #expect(executeFixture.client.session == nil) + + // After a submitted transaction, the status lookup failure keeps its txnId. + let pollingFixture = try preparedFixture(txnId: "txn-polling-expired") + try pollingFixture.transport.enqueue(ExecuteResponse(status: .pending), for: WaasAPI.Execute.urlPath) + pollingFixture.transport.enqueueRawHTTPError( + statusCode: 400, + body: keyExpired, + for: WaasAPI.TransactionStatusMethod.urlPath + ) + await expectPublicError( + try await pollingFixture.client.sendTransaction(network: .polygon, request: request), + equals: error( + code: .transactionStatusLookupFailed, + operation: .walletTransactionStatus, + message: "Transaction was submitted, but status polling failed", + status: 400, + retryable: true, + txnId: "txn-polling-expired", + upstreamError: upstream( + service: .waas, + name: "KeyExpired", + code: "7203", + message: "key expired", + status: 400 + ) + ) + ) + #expect(pollingFixture.client.session == nil) +} + @Test func TestPublicErrorContractsTransactionStatusPollingFailuresPreserveTxnAndUpstreamDetails() async throws { let backendFixture = makeRestoredWalletClient() try backendFixture.transport.enqueue( diff --git a/docs/error-contracts.md b/docs/error-contracts.md index de7d45c..0893e36 100644 --- a/docs/error-contracts.md +++ b/docs/error-contracts.md @@ -66,7 +66,9 @@ should be present, and which tests own the contract. | OIDC redirect and ID-token auth methods | Local OIDC config or validated callback failure | Thrown `OMSWalletError` with `.sessionMissing` or `.validationError` | Fix redirect config or restart OIDC flow | Absent | `PublicErrorContractsTests.swift` | | `handleOIDCRedirectCallback` | URL or state does not match the pending redirect | `.notOIDCRedirectCallback` result | Ignore the unrelated callback; the pending redirect remains available | Absent | `MockWalletTests.swift` | | `omsWallet.wallet.startOIDCRedirectAuth`, `handleOIDCRedirectCallback` | Local OIDC redirect-state persistence or restore failure | Thrown `OMSWalletError` with `.storageError` | Retry OIDC auth after resolving the local storage issue | Absent | `PublicErrorContractsTests.swift` | -| Protected wallet methods: `getIdToken`, signing and transaction methods, wallet import, `getTransactionStatus`, access authorization/usage/listing/revocation | Missing or expired local session | `.sessionMissing` or `.sessionExpired` | Authenticate again or recover local session; no remote request was made | Absent | `PublicErrorContractsTests.swift` | +| Protected wallet methods: `getIdToken`, signing and transaction methods, wallet import, `getTransactionStatus`, access authorization/usage/listing/revocation | Missing or expired local session | `.sessionMissing` or `.sessionExpired` | Authenticate again or recover local session. A locally expired session is first refreshed with `GetCredential`; no other request is made. A server that extended the session lets the method continue | Absent | `PublicErrorContractsTests.swift`, `CredentialExpiryRefreshTests.swift` | +| Protected wallet methods above, and `useWallet`, `createWallet`, `listWallets` with an active wallet | WaaS answers a request signed with the active session's credential with not found (`KeyNotFound`, code `7202`) or expired (`KeyExpired`, code `7203`) | `.sessionExpired` with the method's operation; the session ends and session-expired observers fire once. This includes a rejected `Execute`: WaaS rejects the credential before the handler runs, so nothing was sent and the error is `.sessionExpired` without `txnId`, not `.transactionExecutionUnconfirmed`. Status polling after a submitted transaction keeps `.transactionStatusLookupFailed` with its `txnId` (and the session still ends). `Unauthorized` (`7207`), sign-in flows, and unsigned public calls (`inspectRemoteCredential`, `isValid*`) are not mapped: their `7202` refers to another credential and stays `.requestFailed` | Authenticate again | Absent | `PublicErrorContractsTests.swift` | +| Protected wallet methods above | The expiry refresh for a locally expired session fails with a transport or other non-credential error | The refresh's error, normally `.requestFailed` with `retryable == true`; the session is kept | Retry when the wallet API is reachable | Present | `CredentialExpiryRefreshTests.swift` | | `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called without `walletAddress` | No active wallet, or the active wallet belongs to another chain family | `.sessionMissing` (or `.sessionExpired`), or `.validationError` | Sign in with a wallet of the method's family, or pass `walletAddress`; with an address no session is required. No remote request was made | Absent | `SignatureVerificationTests.swift` | | `isValidMessageSignature`, `isValidTypedDataSignature`, `isValidSolanaMessageSignature`, `isValidTronMessageSignature`, `isValidTronTypedDataSignature` called with an empty or whitespace-only `walletAddress` | Blank address; it does not fall back to the active wallet | `.validationError` ("walletAddress must not be empty") | Pass a wallet address, or omit `walletAddress` to use the active wallet. No remote request was made | Absent | `SignatureVerificationTests.swift` | | Wallet signing, transactions, import, and owner access methods | SDK-local validation, wallet-family mismatch (checked against the active wallet's stored type), non-bare contract `method` name, or fee-selection failure | `.validationError` | Correct parameters, select the required wallet family, or fix local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift`, `WalletImportTests.swift`, `MockWalletTests.swift`, `TronWalletTests.swift` |