diff --git a/AGENTS.md b/AGENTS.md index 8db450d..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 @@ -106,9 +108,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. @@ -125,10 +128,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 06c1c00..e5fe179 100644 --- a/API.md +++ b/API.md @@ -22,25 +22,40 @@ 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 } ``` -### `WalletClient.walletId` +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.session` ```swift -public internal(set) var walletId: String { get set } +public var session: OMSWalletSession? { get } ``` -### `WalletClient.session` +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 var session: OMSWalletSessionState { get } +public static let maxSessionLifetimeSeconds: UInt32 ``` -Snapshot of the current durable wallet-session state. +The longest session lifetime the wallet API accepts (180 days). ### `WalletClient.addSessionExpiredObserver(_:)` @@ -51,7 +66,7 @@ Snapshot of the current durable wallet-session state. ### `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. @@ -67,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 @@ -108,28 +123,28 @@ 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 +Activates an existing wallet by its WaaS wallet ID and persists the wallet and 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 -its address and signer metadata to the keychain. +the wallet 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 or Solana private key. +Imports and activates an Ethereum, Solana, or Tron private key. ### `WalletClient.getWalletImportRecipientKey(cipherSuite:)` @@ -142,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. @@ -246,14 +261,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 } } ``` @@ -265,18 +278,17 @@ 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 let walletAddress: String +public struct WalletActivationResult: Sendable { public let wallet: Wallet - public init(walletAddress: String, wallet: Wallet) + public init(wallet: Wallet) } ``` @@ -300,6 +312,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 +355,12 @@ 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) + public var walletType: WalletType { get } } ``` @@ -373,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) @@ -549,20 +571,23 @@ 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 { + /// 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 + public init(expiresAt: String, 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 +640,13 @@ public enum OMSWalletOidcSessionAuthFlow: String, Codable, Equatable, Sendable { ```swift public struct OMSWalletSessionExpiredEvent: Equatable, Sendable { - public let session: OMSWalletSessionState - public let expiredAt: Date - public init(session: OMSWalletSessionState, expiredAt: Date) + /// 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 + /// The ISO-8601 expiry timestamp of the session that expired. + public let expiredAt: String + public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: String) } ``` @@ -663,22 +692,46 @@ 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 -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? = nil, message: String, signature: String) async throws -> Bool +``` + +### `WalletClient.isValidTronTypedDataSignature(walletAddress:typedData:signature:)` + +```swift +public func isValidTronTypedDataSignature(walletAddress: String? = nil, typedData: JSONValue, signature: String) async throws -> Bool ``` ### `WalletClient.sendTransaction(network:to:value:selectFeeOption:mode:waitForStatus:statusPolling:)` @@ -699,10 +752,22 @@ 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.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)` +### `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: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:contractAddress:method:args:selectFeeOption:waitForStatus:statusPolling:)` + +```swift +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:)` @@ -940,6 +1005,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 } } ``` @@ -1029,6 +1107,141 @@ 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 + 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 } +} +``` + +### `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 @@ -1370,7 +1583,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 @@ -1383,9 +1596,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 } @@ -1419,6 +1630,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 @@ -1471,6 +1702,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) } ``` @@ -1485,8 +1717,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" @@ -1509,14 +1741,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) } @@ -1538,8 +1777,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) } ``` @@ -1557,11 +1796,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/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 5267617..46de158 100644 --- a/Examples/sdk-demo/oms-wallet-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-wallet-demo/ContentView.swift @@ -46,12 +46,10 @@ struct SessionExpiredPrompt: Identifiable { let event: OMSWalletSessionExpiredEvent var email: String? { - event.session.auth?.email + event.session.auth.email } } -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" } @@ -255,6 +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( @@ -272,19 +270,65 @@ 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 { + reauthWalletType ?? .ethereum + } + var sessionLifetimeSeconds: UInt32? { let trimmed = sessionLifetimeText.trimmingCharacters(in: .whitespacesAndNewlines) guard let seconds = UInt32(trimmed), seconds >= 1, - seconds <= maxSessionLifetimeSeconds else { + seconds <= WalletClient.maxSessionLifetimeSeconds else { return nil } return seconds } func checkSession() async { - let hasSession = omsWallet.wallet.walletAddress != nil - screen = hasSession ? .wallet : .introduction + 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 = .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() { @@ -295,6 +339,7 @@ final class AppViewModel: ObservableObject { try omsWallet.wallet.signOut() safariAuthSession = nil sessionExpiredPrompt = nil + reauthWalletType = nil screen = .introduction } catch { present(error) @@ -343,6 +388,7 @@ final class AppViewModel: ObservableObject { idToken: idToken, issuer: googleIssuer, audience: googleWebClientId, + walletType: walletType, walletSelection: walletSelectionBehavior, sessionLifetimeSeconds: lifetimeSeconds, provider: "google", @@ -351,7 +397,7 @@ final class AppViewModel: ObservableObject { switch result { case .walletSelected: - screen = .wallet + showWallet() case .walletSelection(let pendingSelection): screen = .walletSelection(pendingSelection) } @@ -432,6 +478,7 @@ final class AppViewModel: ObservableObject { let started = try await omsWallet.wallet.startOIDCRedirectAuth( provider: provider, omsRelayReturnURI: oidcRedirectUri, + walletType: walletType, walletSelection: walletSelectionBehavior, sessionLifetimeSeconds: lifetimeSeconds ) @@ -458,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) @@ -480,11 +527,12 @@ final class AppViewModel: ObservableObject { do { let result = try await omsWallet.wallet.completeEmailAuth( code: code, - walletSelection: walletSelectionBehavior + walletSelection: walletSelectionBehavior, + walletType: walletType ) switch result { case .walletSelected: - screen = .wallet + showWallet() case .walletSelection(let pendingSelection): screen = .walletSelection(pendingSelection) } @@ -513,18 +561,19 @@ final class AppViewModel: ObservableObject { } catch { present(error) } + reauthWalletType = nil screen = .login } private func completeWalletSelection( - _ operation: () async throws -> WalletSelectionResult + _ operation: () async throws -> WalletActivationResult ) async { isLoading = true defer { isLoading = false } do { _ = try await operation() - screen = .wallet + showWallet() } catch { present(error) } @@ -553,7 +602,7 @@ final class AppViewModel: ObservableObject { } loginEmail = email await submitLogin() - case .oidc, nil: + case .oidc: screen = .login } } @@ -567,9 +616,11 @@ 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 } + // Sign the user back in to the same wallet family. + reauthWalletType = DemoWalletType(event.wallet?.type)?.walletType screen = .login sessionExpiredPrompt = SessionExpiredPrompt(event: event) } @@ -914,7 +965,7 @@ struct WalletSelectionWindow: View { WalletSelectionRow( title: "No \(walletTypeLabel) wallets", subtitle: "Create a wallet to continue", - leadingText: "0x", + leadingText: walletLeadingText, isEnabled: false ) } else { @@ -925,7 +976,7 @@ struct WalletSelectionWindow: View { WalletSelectionRow( title: shortWalletAddress(wallet.address), subtitle: walletSelectionSubtitle(wallet), - leadingText: "0x" + leadingText: walletLeadingText ) } .buttonStyle(.plain) @@ -958,6 +1009,14 @@ struct WalletSelectionWindow: View { pendingSelection.walletType.wireValue } + private var walletLeadingText: String { + switch pendingSelection.walletType { + case .tron: "T" + case .solana: "SOL" + default: "0x" + } + } + private func walletSelectionSubtitle(_ wallet: Wallet) -> String { [ nonEmpty(wallet.reference), @@ -981,6 +1040,68 @@ struct WalletSelectionWindow: View { } } +enum DemoWalletType: String, CaseIterable, Identifiable { + case ethereum = "Ethereum" + case solana = "Solana" + case tron = "Tron" + + 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 + case .solana: .solana + case .tron: .tron + } + } +} + +/// 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") { + 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) + } + } + } + } +} + private struct ManualWalletSelectionToggle: View { @EnvironmentObject private var vm: AppViewModel @@ -1056,7 +1177,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 = "—" @@ -1066,7 +1187,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 +1226,17 @@ struct WalletWindow: View { } var body: some View { + switch vm.omsWallet.wallet.activeWallet?.type { + case .tron: + TronWalletWindow() + case .solana: + SolanaWalletWindow() + default: + evmWalletTabs + } + } + + private var evmWalletTabs: some View { TabView { walletTab .tabItem { @@ -1142,6 +1274,7 @@ struct WalletWindow: View { ScrollView { VStack(spacing: 24) { walletHeader + WalletTypeSwitcher(isDisabled: isFetchingBalance) walletAddressBar assetSection } @@ -1183,7 +1316,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 +1324,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 +1337,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") } @@ -1274,7 +1407,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) } @@ -1291,7 +1424,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() @@ -1352,7 +1485,7 @@ struct WalletWindow: View { } private var sessionSubtitle: String { - sessionAuthLabel(vm.omsWallet.wallet.session.auth) + sessionAuthLabel(vm.omsWallet.wallet.session?.auth) } } @@ -1374,6 +1507,1074 @@ private func sessionAuthLabel(_ auth: OMSWalletSessionAuth?) -> String { } } +// MARK: - Tron Wallet Window + +private let usdtDecimals = 6 +private let trxDecimals = 6 + +private struct TronNetworkConfig { + let network: TronNetwork + let label: String + let explorerURL: String + let usdtContract: String + let faucetURL: URL? +} + +/// 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 { + trimBalanceDisplay((try? formatUnits(value: raw, decimals: decimals)) ?? raw, maxFractionDigits: decimals) +} + +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(network: $network, isBusy: isSendBusy || isSignBusy) + .tabItem { + Label("Wallet", systemImage: "wallet.pass") + } + + TronSendWindow(network: $network, isSendBusy: $isSendBusy, isSignBusy: isSignBusy) + .tabItem { + Label("Send", systemImage: "arrow.up.circle") + } + + TronSignMessageWindow(isBusy: $isSignBusy) + .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 + @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: "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 || vm.switchingWalletType != nil + ) + + if let address = vm.omsWallet.wallet.activeWallet?.address { + FieldGroup(title: "Address") { + CopyableResult(text: address) + if let url = tronAddressExplorerURL(address, config: config) { + Link("View wallet on Tronscan", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + + DesignText(sessionAuthLabel(vm.omsWallet.wallet.session?.auth), variant: .caption) + + Panel { + 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) + } + } + + 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) + .onChange(of: network) { _ in + trxBalance = "—" + usdtBalance = "—" + balanceStatus = nil + } + .task(id: network) { + 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) + if let faucetURL = config.faucetURL { + 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 } + let config = config + balanceRequest += 1 + let request = balanceRequest + isFetchingBalance = true + balanceStatus = nil + defer { + if request == balanceRequest { isFetchingBalance = false } + } + + do { + let result = try await vm.omsWallet.indexer.getTronBalances( + GetTronBalancesParams( + walletAddress: address, + networks: [config.network], + contractAddresses: [config.usdtContract] + ) + ) + // 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 + 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 == config.usdtContract: + usdtRaw = token.balance + case .fungibleToken: + break + } + } + trxBalance = formatTokenAmount(trxRaw, decimals: trxDecimals) + usdtBalance = formatTokenAmount(usdtRaw, decimals: usdtDecimals) + } catch { + guard request == balanceRequest else { return } + 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 + @Binding var network: TronDemoNetwork + @Binding var isSendBusy: Bool + let isSignBusy: Bool + + @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: 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 + Text(asset.rawValue).tag(asset) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + if asset == .usdt { + DesignText("USDT contract address: \(network.config.usdtContract)", 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: network.sendTitle, 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, config: network.config) { + Link("View on Tronscan", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + .genericErrorWindow(error: $error) + .sheet(item: $vm.feeOptionSelectionRequest) { request in + FeeOptionSelectionWindow(request: request) + .environmentObject(vm) + } + .onChange(of: network) { _ in + result = nil + transferStatus = nil + } + .onChange(of: isSending) { isSendBusy = $0 } + } + + 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) + let config = network.config + + do { + let transaction: SendTransactionResponse + switch asset { + case .trx: + transaction = try await vm.omsWallet.wallet.sendTronTransaction( + network: config.network, + to: recipient, + value: try parseUnits(value: amount, decimals: trxDecimals), + selectFeeOption: selectFeeOption, + statusPolling: statusPolling + ) + case .usdt, .custom: + let contract = asset == .usdt + ? config.usdtContract + : tokenContractText.trimmingCharacters(in: .whitespacesAndNewlines) + 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: config.network, + contractAddress: 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 + @Binding var isBusy: Bool + + @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) + .onChange(of: isSigning) { isBusy = $0 } + } + + 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: - Solana Wallet Window + +private let solanaExplorerURL = "https://explorer.solana.com" +private let usdcDecimals = 6 +private let solDecimals = 9 + +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 solanaExplorerURL(_ path: String, config: SolanaNetworkConfig) -> URL? { + let cluster = config.explorerCluster.map { "?cluster=\($0)" } ?? "" + return URL(string: "\(solanaExplorerURL)/\(path)\(cluster)") +} + +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 + + @State private var network: SolanaDemoNetwork = .devnet + @State private var isSendBusy: Bool = false + @State private var isSignBusy: Bool = false + + var body: some View { + TabView { + SolanaBalancesWindow(network: $network, isBusy: isSendBusy || isSignBusy) + .tabItem { + Label("Wallet", systemImage: "wallet.pass") + } + + SolanaSendWindow(network: $network, isSendBusy: $isSendBusy, isSignBusy: isSignBusy) + .tabItem { + Label("Send", systemImage: "arrow.up.circle") + } + + SolanaSignMessageWindow(isBusy: $isSignBusy) + .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 + @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: "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 || vm.switchingWalletType != nil + ) + + if let address = vm.omsWallet.wallet.activeWallet?.address { + FieldGroup(title: "Address") { + CopyableResult(text: address) + if let url = solanaExplorerURL("address/\(address)", config: config) { + Link("View wallet on Solana Explorer", destination: url) + .font(.caption.weight(.semibold)) + } + } + } + + DesignText(sessionAuthLabel(vm.omsWallet.wallet.session?.auth), variant: .caption) + + Panel { + solanaBalanceRow( + title: "\(config.label) SOL balance", + value: "\(solBalance) SOL", + faucetTitle: "Open SOL faucet", + faucetURL: config.solFaucetURL + ) + solanaBalanceRow( + title: "\(config.label) USDC balance", + value: "\(usdcBalance) USDC", + faucetTitle: "Open USDC faucet", + faucetURL: config.usdcFaucetURL + ) + + 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) + .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 { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + DesignText(title, variant: .caption) + if let faucetURL { + 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 } + let config = config + balanceRequest += 1 + let request = balanceRequest + isFetchingBalance = true + balanceStatus = nil + defer { + if request == balanceRequest { isFetchingBalance = false } + } + + do { + let result = try await vm.omsWallet.indexer.getSolanaBalances( + GetSolanaBalancesParams( + walletAddress: address, + networks: [config.network], + mintAddresses: [config.usdcMint] + ) + ) + // 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 + return + } + + var solRaw = "0" + var usdcRaw = "0" + for balance in result.balances where balance.network == config.network { + switch balance { + case .native: + solRaw = balance.balance + case .fungibleToken(let token) where token.mintAddress == config.usdcMint: + usdcRaw = balance.balance + case .fungibleToken: + break + } + } + solBalance = formatTokenAmount(solRaw, decimals: solDecimals) + usdcBalance = formatTokenAmount(usdcRaw, decimals: usdcDecimals) + } catch { + guard request == balanceRequest else { return } + 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 + @Binding var network: SolanaDemoNetwork + @Binding var isSendBusy: Bool + let isSignBusy: Bool + + @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: 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 + Text(asset.rawValue).tag(asset) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(isSending) + + if asset == .usdc { + DesignText("USDC mint address: \(network.config.usdcMint)", 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: network.sendTitle, 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 = solanaExplorerURL("tx/\(signature)", config: network.config) { + 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" + } + .onChange(of: network) { _ in + result = nil + transferStatus = nil + } + .onChange(of: isSending) { isSendBusy = $0 } + } + + 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) + } + let config = network.config + + do { + let transferAsset: String + let baseUnits: String + switch asset { + case .sol: + transferAsset = "SOL" + baseUnits = try solanaBaseUnits(amount, decimals: solDecimals, label: "SOL") + case .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: config.network, + 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 + @Binding var isBusy: Bool + + @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) + .onChange(of: isSigning) { isBusy = $0 } + } + + 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 { @@ -1381,7 +2582,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? @@ -1446,7 +2647,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? @@ -1521,7 +2722,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 } } @@ -1575,7 +2776,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"), @@ -1655,7 +2856,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/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, diff --git a/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift b/Examples/trails-actions/trails-actions/TrailsDemoViewModel.swift index 969cba4..f4aa6cc 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) } } @@ -122,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) } @@ -131,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) } @@ -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 @@ -227,18 +239,18 @@ 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) } } @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 { @@ -869,14 +885,14 @@ 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 = "" 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..008e164 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,6 +3,311 @@ 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` (now `WalletActivationResult`, see +below) 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 +``` + +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 +`OMSWalletSession?`. Previously it returned a value whose fields were all `nil`. The +`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 +// 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. 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. 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 + +`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. + +### 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. +- 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 +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) +``` + +### 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 +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. + +### Empty Solana balance metadata + +`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`, `ContractTokenBalance` uses +`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 4aac4bf..114a5d3 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 @@ -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,16 +97,16 @@ 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 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, 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 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,21 +117,43 @@ 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. Its +`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 one week, and custom values must be from 1 -through 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: +longer email session. The default is `WalletClient.defaultSessionLifetimeSeconds` (one week), and +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 - 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 +201,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 +221,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. @@ -255,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. @@ -296,8 +318,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 @@ -309,29 +331,30 @@ verified. ```swift let signature = try await omsWallet.wallet.signMessage( - network: .polygonAmoy, + network: .amoy, message: "hello from OMS Wallet" ) -guard let walletAddress = omsWallet.wallet.walletAddress 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 solanaWalletAddress = omsWallet.wallet.walletAddress else { return } - 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 ) @@ -361,15 +384,12 @@ let typedData: JSONValue = .object([ ]) let signature = try await omsWallet.wallet.signTypedData( - network: .polygonAmoy, + network: .amoy, typedData: typedData ) -guard let walletAddress = omsWallet.wallet.walletAddress else { return } - let typedDataValid = try await omsWallet.wallet.isValidTypedDataSignature( - network: .polygonAmoy, - walletAddress: walletAddress, + network: .amoy, typedData: typedData, signature: signature ) @@ -378,7 +398,8 @@ let typedDataValid = try await omsWallet.wallet.isValidTypedDataSignature( ### Query Balances ```swift -guard let walletAddress = omsWallet.wallet.walletAddress 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( @@ -425,12 +446,13 @@ print(balance?.balance ?? "0") ### Query Transaction History ```swift -guard let walletAddress = omsWallet.wallet.walletAddress 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( walletAddress: walletAddress, - networks: [.polygonAmoy], + networks: [.amoy], includeMetadata: true ) ) @@ -462,7 +484,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 ) @@ -481,7 +503,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, @@ -496,9 +518,9 @@ 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", - method: "transfer(address,uint256)", + network: .amoy, + contractAddress: "0x3333333333333333333333333333333333333333", + method: "transfer", args: [ AbiArg(type: "address", value: .string("0x1111111111111111111111111111111111111111")), AbiArg(type: "uint256", value: .string(amount)), @@ -506,6 +528,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`. @@ -513,7 +539,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 @@ -539,7 +565,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( @@ -555,7 +581,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 @@ -570,15 +596,94 @@ 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 +) + +// 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: recipient, + 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, + contractAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + method: "transfer", + args: [ + AbiArg(type: "address", value: .string(recipient)), + 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 structure as `getSolanaBalances`: +precision-safe raw and formatted balance strings, with TRC-20 entries identified by +`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. + +```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 @@ -597,7 +702,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 @@ -612,7 +718,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` | @@ -635,6 +741,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. @@ -669,11 +782,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 ) @@ -780,8 +898,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/Clients/IndexerClient.swift b/Sources/OMSWallet/Clients/IndexerClient.swift index e5930d2..64f2106 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, @@ -330,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/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 164094a..8d9fbe4 100644 --- a/Sources/OMSWallet/Clients/WalletAuth.swift +++ b/Sources/OMSWallet/Clients/WalletAuth.swift @@ -11,10 +11,10 @@ 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 = 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( @@ -564,6 +564,13 @@ extension WalletClient { requiredSessionRevision: UInt64, 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 { + clearSessionIfStillCurrent(requiredSessionRevision) + } + throw OMSWalletError(code: .invalidResponse, message: "Auth response has an invalid credential expiresAt") + } let sessionMetadata = SessionMetadata( expiresAt: response.credential.expiresAt, auth: sessionAuth @@ -578,13 +585,11 @@ 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, + requiredSessionRevision: requiredSessionRevision + ) { + try await walletsFromAuthResponse(response) } try withOptionalOIDCRedirectAuthOwnership(oidcRedirectAuthOwnership) { try requireCurrentSessionRevision(requiredSessionRevision) @@ -607,45 +612,27 @@ extension WalletClient { ) } - let activated: WalletSelectionResult - 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, + requiredSessionRevision: requiredSessionRevision + ) { + 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( - walletAddress: activated.walletAddress, wallet: activated.wallet, wallets: candidateWallets.isEmpty ? wallets + [activated.wallet] : wallets, credential: response.credential.walletCredential @@ -719,14 +706,15 @@ extension WalletClient { guard activePendingWalletSelection?.id == selectionSession.id else { throw OMSWalletError.walletSelectionStale() } - let selectionSessionState = OMSWalletSessionState( - walletAddress: nil, - expiresAtString: selectionSession.metadata.expiresAt, + if let selectionSessionState = WalletSessionSnapshot( + wallet: nil, + expiresAt: selectionSession.metadata.expiresAt, auth: selectionSession.metadata.auth - ) - guard !isSessionExpired(selectionSessionState) else { - expireSession(selectionSessionState) - throw OMSWalletError.sessionExpired() + ) { + guard !isSessionExpired(selectionSessionState) else { + expireSession(selectionSessionState) + throw OMSWalletError.sessionExpired() + } } try requireActiveCredential() let signerCredentialId = try credentialSession.signer.credentialId() @@ -736,23 +724,38 @@ 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?, + requiredSessionRevision: UInt64, + _ 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 { throw error } catch { - try? signOut() + clearSessionIfStillCurrent(requiredSessionRevision) throw error } } - /// Activates an existing wallet by its WaaS wallet ID and persists its address and + /// 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 - public func useWallet(walletId: String) async throws -> WalletSelectionResult { - try await runOMSWalletOperation(.walletUseWallet) { - try requireWalletSelectionOrActiveSession() + public func useWallet(walletId: String) async throws -> WalletActivationResult { + try await runSessionOperation(.walletUseWallet) { + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() let activationRevision = sessionRevisionSnapshot() return try await useWallet( @@ -764,19 +767,19 @@ 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. /// - /// - 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, reference: String? = nil - ) async throws -> WalletSelectionResult { - try await runOMSWalletOperation(.walletCreateWallet) { - try requireWalletSelectionOrActiveSession() + ) async throws -> WalletActivationResult { + try await runSessionOperation(.walletCreateWallet) { + try await requireWalletSelectionOrActiveSession() try requireActiveCredential() let activationRevision = sessionRevisionSnapshot() return try await createWallet( @@ -790,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) } @@ -803,25 +806,22 @@ extension WalletClient { sessionMetadata: SessionMetadata, requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { let params = CreateWalletRequest( networkFamily: walletType.waasNetworkFamily, reference: reference ) 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 WalletActivationResult(wallet: wallet) } /// Loads an existing wallet by ID for the authenticated user and persists @@ -836,24 +836,21 @@ extension WalletClient { sessionMetadata: SessionMetadata, requiredSessionRevision: UInt64, oidcRedirectAuthOwnership: PendingOIDCRedirectAuth? = nil - ) async throws -> WalletSelectionResult { + ) async throws -> WalletActivationResult { let params = UseWalletRequest( walletId: walletId ) 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 WalletActivationResult(wallet: wallet) } private func walletsFromAuthResponse(_ response: CompleteAuthResponse) async throws -> [Wallet] { @@ -890,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 f591c11..a00e6d2 100644 --- a/Sources/OMSWallet/Clients/WalletClient.swift +++ b/Sources/OMSWallet/Clients/WalletClient.swift @@ -17,6 +17,19 @@ 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 (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 static let defaultFastTransactionStatusPollCount = 5 @@ -29,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 @@ -89,32 +104,34 @@ public final class WalletClient: @unchecked Sendable { withSessionLock { _latestSessionExpiredEvent = newValue } } } - private var _walletAddress: String - private var _walletId: String + 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 = "" 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 { - get { - withSessionLock { _walletId } - } - set { - withSessionLock { _walletId = newValue } - } + /// The active wallet's ID, or `""` when there is none. + var walletId: String { + withSessionLock { _activeWallet?.id ?? "" } } var verifier: String { get { @@ -180,10 +197,6 @@ public final class WalletClient: @unchecked Sendable { } } - func clearAllPendingOIDCRedirectAuth() throws { - try withOIDCRedirectAuthProjectLock { try oidcRedirectAuthStore.clear() } - } - func withOIDCRedirectAuthOwnership( _ pending: PendingOIDCRedirectAuth, _ body: () throws -> T @@ -240,8 +253,7 @@ public final class WalletClient: @unchecked Sendable { ) } - self._walletId = "" - self._walletAddress = "" + self._activeWallet = nil self._sessionExpiresAt = nil self._sessionAuth = nil self.credentialSession = credentialSession @@ -255,6 +267,7 @@ public final class WalletClient: @unchecked Sendable { publishableKey: publishableKey, environment: environment ) + observeSessionCredentialClients() restoreStoredWalletSession(storedWallet) } @@ -284,8 +297,7 @@ public final class WalletClient: @unchecked Sendable { let makeSignedClient = signedClientFactory ?? { _ in signedClient } self.signedClientFactory = makeSignedClient - self._walletId = "" - self._walletAddress = "" + self._activeWallet = nil self._sessionExpiresAt = nil self._sessionAuth = nil self.credentialSession = credentialSession @@ -296,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 @@ -374,13 +411,14 @@ 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() } 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 } @@ -389,14 +427,13 @@ 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() } - let walletId = withSessionLock { - _walletId.trimmingCharacters(in: .whitespacesAndNewlines) - } + let walletId = self.walletId.trimmingCharacters(in: .whitespacesAndNewlines) guard !walletId.isEmpty else { throw OMSWalletError.sessionMissing() } @@ -404,10 +441,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,33 +460,42 @@ 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 ) 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) 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._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) } - self.scheduleSessionExpiry(self.session) } if let oidcRedirectAuthOwnership { try withOIDCRedirectAuthOwnership(oidcRedirectAuthOwnership, persist) diff --git a/Sources/OMSWallet/Clients/WalletImports.swift b/Sources/OMSWallet/Clients/WalletImports.swift index b0a8675..5ef3fb7 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. @@ -31,9 +31,9 @@ extension WalletClient { public func importWallet( privateKey: WalletImportPrivateKey, reference: String? = nil - ) async throws -> WalletSelectionResult { - try await runOMSWalletOperation(.walletImportWallet) { - let context = try walletImportActivationContext(for: privateKey.walletType) + ) async throws -> WalletActivationResult { + 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 } } @@ -82,16 +82,16 @@ extension WalletClient { walletType: WalletType, keyMaterial: EncryptedWalletImportKeyMaterial, reference: String? = nil - ) async throws -> WalletSelectionResult { - try await runOMSWalletOperation(.walletImportEncryptedWallet) { - let context = try walletImportActivationContext(for: walletType) + ) async throws -> WalletActivationResult { + 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, _): @@ -191,17 +191,16 @@ extension WalletClient { private func activateImportedWallet( _ wallet: Wallet, context: WalletImportActivationContext - ) throws -> WalletSelectionResult { + ) throws -> WalletActivationResult { 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 WalletActivationResult(wallet: wallet) } private func requireWalletImportClient() throws -> WaasClient { diff --git a/Sources/OMSWallet/Clients/WalletOperations.swift b/Sources/OMSWallet/Clients/WalletOperations.swift index 779dba9..ded1af6 100644 --- a/Sources/OMSWallet/Clients/WalletOperations.swift +++ b/Sources/OMSWallet/Clients/WalletOperations.swift @@ -9,12 +9,12 @@ 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.chainId, + network: network.waasChainId, walletId: walletId, message: message ) @@ -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,12 +36,12 @@ 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.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 await 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 await 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 await 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 ) @@ -114,6 +114,64 @@ extension WalletClient { } } + public func signTronMessage(message: String) async throws -> String { + 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) + ).signature + } + } + + public func signTronTypedData(typedData: JSONValue) async throws -> String { + 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) + ).signature + } + } + + public func isValidTronMessageSignature( + walletAddress: String? = nil, + message: String, + signature: String + ) async throws -> Bool { + try await runOMSWalletOperation(.walletIsValidTronMessageSignature) { + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .tron) + return try await publicClient.isValidMessageSignature( + IsValidMessageSignatureRequest( + networkFamily: .tron, + walletAddress: walletAddress, + message: message, + signature: signature + ) + ).isValid + } + } + + public func isValidTronTypedDataSignature( + walletAddress: String? = nil, + typedData: JSONValue, + signature: String + ) async throws -> Bool { + try await runOMSWalletOperation(.walletIsValidTronTypedDataSignature) { + let walletAddress = try await verificationWalletAddress(walletAddress, walletType: .tron) + return try await publicClient.isValidTypedDataSignature( + IsValidTypedDataSignatureRequest( + networkFamily: .tron, + walletAddress: walletAddress, + typedData: typedData.waasValue, + signature: signature + ) + ).isValid + } + } + public func sendTransaction( network: Network, to: String, @@ -123,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( @@ -152,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( @@ -179,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( @@ -205,6 +263,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 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, + // 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, + contractAddress: String, + method: String, + args: [AbiArg]? = nil, + selectFeeOption: FeeOptionSelector? = nil, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() + ) async throws -> SendTransactionResponse { + try await runSessionOperation(.walletCallTronContract) { + try await requireActiveTronWallet() + let walletId = try await 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: contractAddress, + 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, @@ -216,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, @@ -237,24 +368,25 @@ 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, 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) 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 @@ -277,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) @@ -381,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] @@ -402,6 +531,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 @@ -425,13 +560,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( @@ -446,7 +575,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 @@ -459,7 +588,7 @@ extension WalletClient { balance = nativeBalance } else { balance = normalizedAddress(feeOption.token.contractAddress) - .flatMap { balancesByContract[$0] ?? nil } + .flatMap { balancesByContract[$0] } } let decimals = feeOption.token.balanceDecimals @@ -479,47 +608,79 @@ extension WalletClient { walletAddress: String, feeOptions: [FeeOption] ) async -> [FeeOptionWithBalance] { - let mintAddresses = feeOptions - .filter { !$0.token.isNativeToken } - .compactMap { normalizedSolanaAddress($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 + 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 false } - var balancesByMint: [String: SolanaBalance] = [:] - for balance in balances?.balances ?? [] { - if case .fungibleToken(let value) = balance, value.network == network { - balancesByMint[value.mintAddress] = balance + return feeOptionsWithGatewayBalances(feeOptions, native: native, byAddress: byAddress) + } + + private func enrichTronFeeOptionsWithBalances( + network: TronNetwork, + walletAddress: String, + feeOptions: [FeeOption] + ) async -> [FeeOptionWithBalance] { + let balances = try? await indexerClient.getTronBalances( + GetTronBalancesParams( + walletAddress: walletAddress, + networks: [network], + includeMetadata: false, + omitNativeBalances: !feeOptions.contains { $0.token.isNativeToken }, + contractAddresses: gatewayFeeTokenAddresses(feeOptions) + ) + ) + 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 - : normalizedSolanaAddress(feeOption.token.contractAddress).flatMap { balancesByMint[$0] } + ? native + : normalizedBase58Address(feeOption.token.contractAddress).flatMap { byAddress[$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, + available: formatTokenAmount(balance?.balance, decimals: decimals), + availableRaw: balance?.balance, decimals: decimals ) } @@ -620,42 +781,76 @@ extension WalletClient { return !txnHash.isEmpty } - 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" - ) + private func requireActiveEthereumWallet() async throws { + try await requireActiveWalletType(.ethereum) + } + + private func requireActiveSolanaWallet() async throws { + try await requireActiveWalletType(.solana) + } + + 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) 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 await requireActiveWalletType(walletType).address } - private func requireActiveSolanaWallet() throws { - guard let walletAddress else { + /// Checks the session (expiring it if due) before the stored wallet type; the address shape is + /// not checked. + @discardableResult + func requireActiveWalletType(_ type: WalletType) async throws -> Wallet { + _ = try await requireActiveWalletId() + 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" ) } + return activeWallet } +} - static func isEthereumAddress(_ value: String) -> Bool { - value.hasPrefix("0x") +private extension WalletType { + var displayName: String { + switch self { + case .ethereum: "Ethereum" + case .solana: "Solana" + case .tron: "Tron" + case .unknown(let value): value + } } } -@available(macOS 12.0, iOS 15.0, *) -private extension Array where Element == FeeOption { - func defaultSelection() -> FeeOptionSelection? { - first.map { FeeOptionSelection(feeOption: $0, index: 0) } +// 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) +} + private extension FeeToken { var isNativeToken: Bool { type.caseInsensitiveCompare("native") == .orderedSame @@ -671,22 +866,7 @@ private extension FeeToken { private enum FeeBalanceNetwork { case ethereum(Network) case solana(SolanaNetwork) -} - -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 - } - } + case tron(TronNetwork) } private func normalizedAddress(_ address: String?) -> String? { @@ -697,7 +877,19 @@ private func normalizedAddress(_ address: String?) -> String? { return trimmed.lowercased() } -private func normalizedSolanaAddress(_ address: String?) -> String? { +/// 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 { return nil diff --git a/Sources/OMSWallet/Clients/WalletSessions.swift b/Sources/OMSWallet/Clients/WalletSessions.swift index ee50dec..d5426b6 100644 --- a/Sources/OMSWallet/Clients/WalletSessions.swift +++ b/Sources/OMSWallet/Clients/WalletSessions.swift @@ -1,11 +1,27 @@ 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 { - /// 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,11 +30,15 @@ extension WalletClient { return } - let storedSession = OMSWalletSessionState( - walletAddress: storedWallet.walletAddress, - expiresAtString: storedWallet.expiresAt, + 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 + } guard !isSessionExpired(storedSession) else { expireStoredSession(storedSession) return @@ -27,21 +47,23 @@ extension WalletClient { return } - walletId = restoredWallet.walletId - walletAddress = restoredWallet.walletAddress + activeWallet = restoredWallet.wallet sessionExpiresAt = restoredWallet.expiresAt sessionAuth = restoredWallet.auth - scheduleSessionExpiry(session) + 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() + } + } } - func isSessionExpired(_ session: OMSWalletSessionState) -> Bool { - guard let expiresAt = session.expiresAt else { - return false - } - return currentDate() >= expiresAt + func isSessionExpired(_ session: WalletSessionSnapshot) -> Bool { + currentDate() >= session.expiryDate } - private func expireStoredSession(_ session: OMSWalletSessionState) { + private func expireStoredSession(_ session: WalletSessionSnapshot) { deliverSessionExpiredNotification( withSessionLock { try? credentialSession.clearSignerKeepingCredentials() @@ -51,7 +73,7 @@ extension WalletClient { ) } - func expireSession(_ session: OMSWalletSessionState) { + func expireSession(_ session: WalletSessionSnapshot) { deliverSessionExpiredNotification( withSessionLock { clearActiveSessionForExpiryLocked() @@ -60,22 +82,28 @@ 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 sessionAuth else { + return nil } - return OMSWalletSessionState( - walletAddress: walletAddress, - expiresAtString: sessionExpiresAt, + return WalletSessionSnapshot( + wallet: activeWallet, + expiresAt: sessionExpiresAt, auth: sessionAuth ) } func expireCurrentSessionIfNeeded() -> SessionExpiredNotification? { withSessionLock { - let currentSession = currentSessionLocked() - guard isSessionExpired(currentSession) else { + guard let currentSession = currentSessionLocked(), + isSessionExpired(currentSession) else { return nil } clearActiveSessionForExpiryLocked() @@ -85,12 +113,12 @@ extension WalletClient { private func clearActiveSessionForExpiryLocked() { _sessionRevision += 1 + _lastCredentialRefreshAt = nil sessionExpiryTask?.cancel() sessionExpiryTask = nil activePendingWalletSelection = nil try? credentialSession.clearSignerKeepingCredentials() - walletAddress = nil - walletId = "" + activeWallet = nil verifier = "" challenge = "" pendingEmailAuth = nil @@ -99,11 +127,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,33 +152,25 @@ 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())) - 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 { @@ -164,44 +185,291 @@ extension WalletClient { } } - private func expireSessionFromTimer(_ session: OMSWalletSessionState) { - let transition = withSessionLock { () -> ( - notification: SessionExpiredNotification?, - reschedule: OMSWalletSessionState? - ) 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) + } + ) + } + + /// 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) + } + 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 + } + 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) } - guard isSessionExpired(session) else { - return (nil, session) + 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() - return (makeSessionExpiredNotificationLocked(session), nil) + _serverRejectedSessionRevision = revision + return makeSessionExpiredNotificationLocked(session) } - if let reschedule = transition.reschedule { - scheduleSessionExpiry(reschedule) + 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: 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( @@ -228,66 +496,52 @@ 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._lastCredentialRefreshAt = nil + 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.walletAddress = nil - self.walletId = "" - 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. @@ -313,12 +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() - guard let walletAddress, Self.isEthereumAddress(walletAddress) else { - throw OMSWalletError(code: .validationError, message: "An active Ethereum wallet is required") - } + try await requireActiveWalletType(.ethereum) try validateSmartSessionGrants(grants) let response = try await signedClient.authorizeRemoteAccess( AuthorizeRemoteAccessRequest( @@ -326,11 +578,11 @@ extension WalletClient { walletId: walletId, grants: Grants(entries: grants.map(\.waasValue)), expiry: expiresAt, - chainId: network.chainId, + chainId: network.waasChainId, sessionId: sessionId ) ) - try requireSameActiveWalletSession(walletId) + try await requireSameActiveWalletSession(walletId) return AuthorizedRemoteAccess( walletId: walletId, sessionId: response.sessionId, @@ -364,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( @@ -386,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") @@ -408,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.chainId) + 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 { @@ -431,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, @@ -454,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, @@ -475,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() @@ -517,14 +769,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 @@ -581,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/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..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.9-42835a0a 2adcb9fa810bf56d76cec4c246c328e3ccad75de +// 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.9-42835a0a" -internal static let WEBRPC_SCHEMA_HASH = "2adcb9fa810bf56d76cec4c246c328e3ccad75de" +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.9-42835a0a" +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: @@ -684,6 +689,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 +698,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 +711,8 @@ internal enum WalletType: Codable, Hashable, Sendable, WebRPCEnumKey { self = .ethereum case "solana": self = .solana + case "tron": + self = .tron default: self = .unknown(wireValue) } @@ -724,6 +734,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 +743,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 +756,8 @@ internal enum NetworkFamily: Codable, Hashable, Sendable, WebRPCEnumKey { self = .evm case "solana": self = .solana + case "tron": + self = .tron default: self = .unknown(wireValue) } @@ -804,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 { @@ -812,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 } @@ -823,6 +841,8 @@ internal enum WalletStatus: Codable, Hashable, Sendable, WebRPCEnumKey { self = .active case "suspended": self = .suspended + case "preallocated": + self = .preallocated default: self = .unknown(wireValue) } @@ -1690,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 { @@ -1705,6 +1727,7 @@ internal struct CompleteAuthResponse: Codable, Sendable { case wallets = "wallets" case page = "page" case email = "email" + case claimable = "claimable" case credential = "credential" } } @@ -1819,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 @@ -2277,6 +2328,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 +2687,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 +2704,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" @@ -2724,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" @@ -2922,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" @@ -3048,6 +3205,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" @@ -3150,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( @@ -3326,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( @@ -3438,6 +3663,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 +3961,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/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/WalletActivationResult.swift b/Sources/OMSWallet/Models/Auth/WalletActivationResult.swift new file mode 100644 index 0000000..ab417ae --- /dev/null +++ b/Sources/OMSWallet/Models/Auth/WalletActivationResult.swift @@ -0,0 +1,10 @@ +import Foundation + +@available(macOS 12.0, iOS 15.0, *) +public struct WalletActivationResult: Sendable { + public let wallet: Wallet + + public init(wallet: Wallet) { + self.wallet = wallet + } +} diff --git a/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift b/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift deleted file mode 100644 index 5e0ab9b..0000000 --- a/Sources/OMSWallet/Models/Auth/WalletSelectionResult.swift +++ /dev/null @@ -1,12 +0,0 @@ -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 - self.wallet = wallet - } -} diff --git a/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/SolanaIndexerModels.swift index 0fd7c88..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) @@ -72,6 +113,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/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/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift new file mode 100644 index 0000000..df0a2df --- /dev/null +++ b/Sources/OMSWallet/Models/Indexer/TronIndexerModels.swift @@ -0,0 +1,245 @@ +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 +} + +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 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 { + 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..9fea031 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" @@ -48,20 +49,27 @@ 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 enum OMSWalletUpstreamService: String, Sendable { - case waas = "Waas" - case indexer = "Indexer" + case waas = "waas" + case indexer = "indexer" } public struct OMSWalletUpstreamError: Equatable, Sendable { @@ -292,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, @@ -393,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/Models/Sessions/OMSWalletSession.swift b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift new file mode 100644 index 0000000..f3e600b --- /dev/null +++ b/Sources/OMSWallet/Models/Sessions/OMSWalletSession.swift @@ -0,0 +1,72 @@ +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 { + /// 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 + + public init(expiresAt: String, 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 + /// The ISO-8601 expiry timestamp of the session that expired. + public let expiredAt: String + + public init(wallet: Wallet?, session: OMSWalletSession, expiredAt: String) { + 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? + /// 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/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..30b56e6 100644 --- a/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift +++ b/Sources/OMSWallet/Models/Sessions/StorableCredentials.swift @@ -1,25 +1,76 @@ +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 expiresAt: String let auth: OMSWalletSessionAuth init( - walletId: String, - walletAddress: String, + wallet: Wallet, signerCredentialId: String, alg: SigningAlgorithm, - expiresAt: String? = nil, + expiresAt: String, 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.decode(String.self, forKey: .expiresAt) + self.auth = try container.decode(OMSWalletSessionAuth.self, forKey: .auth) + } + + /// 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 + } + 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 + } + } } diff --git a/Sources/OMSWallet/Models/WaasModelMapping.swift b/Sources/OMSWallet/Models/WaasModelMapping.swift index e5cd429..22622e3 100644 --- a/Sources/OMSWallet/Models/WaasModelMapping.swift +++ b/Sources/OMSWallet/Models/WaasModelMapping.swift @@ -52,32 +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 .unknown(let value): - return .unknown(value) - } - } -} - -extension WaasGenerated.WalletType { - var sdkValue: WalletType { - switch self { - case .ethereum: - return .ethereum - case .solana: - return .solana - case .unknown(let value): - return .unknown(value) - } - } -} - extension WaasGenerated.NetworkFamily { var sdkWalletType: WalletType? { switch self { @@ -85,6 +59,8 @@ extension WaasGenerated.NetworkFamily { return .ethereum case .solana: return .solana + case .tron: + return .tron case .unknown(let value): return .unknown(value) } @@ -163,6 +139,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 +350,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..4169f0c 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) } @@ -270,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/Sources/OMSWallet/Network.swift b/Sources/OMSWallet/Network.swift index 8ff7b10..9689982 100644 --- a/Sources/OMSWallet/Network.swift +++ b/Sources/OMSWallet/Network.swift @@ -12,11 +12,23 @@ 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 case polygon - case polygonAmoy = "amoy" + case amoy case arbitrum case arbitrumSepolia = "arbitrum-sepolia" case optimism @@ -30,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: @@ -40,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 @@ -69,7 +79,7 @@ public enum Network: String, CaseIterable, Sendable, CustomStringConvertible { } } - public var chainId: String { + var waasChainId: String { String(id) } @@ -82,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" @@ -99,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" @@ -140,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" @@ -184,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/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..1343e2e 100644 --- a/Sources/OMSWallet/Signer/WalletCredentialSession.swift +++ b/Sources/OMSWallet/Signer/WalletCredentialSession.swift @@ -3,9 +3,8 @@ import Foundation @available(macOS 12.0, iOS 15.0, *) final class WalletCredentialSession { struct WalletMetadata { - let walletId: String - let walletAddress: String - let expiresAt: 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 ) @@ -64,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) @@ -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, - expiresAt: 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, @@ -140,10 +135,6 @@ final class WalletCredentialSession { } } - private func makeCredentialSigner() -> any CredentialSigner { - signerFactory() - } - private func signerMatchesStoredCredential( _ signer: any CredentialSigner, credentials: StorableCredentials @@ -156,7 +147,7 @@ final class WalletCredentialSession { } private func clearStoredSession() { - _ = try? makeCredentialSigner().clear() + _ = try? signerFactory().clear() _ = try? keychain.delete(forKey: credentialsStorageKey) currentSigner = signerFactory() } @@ -169,28 +160,14 @@ final class WalletCredentialSession { return try body() } - private static func sessionIsExpired(expiresAt value: String?) -> Bool { - guard let expiresAt = parseExpiresAt(value) else { + private static func sessionIsExpired(expiresAt value: String) -> Bool { + 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, 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? { + guard let value = try decodeIfPresent(String.self, forKey: key), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Sources/OMSWallet/Utils/EncodableExtension.swift b/Sources/OMSWallet/Utils/EncodableExtension.swift index 058622f..72c4059 100644 --- a/Sources/OMSWallet/Utils/EncodableExtension.swift +++ b/Sources/OMSWallet/Utils/EncodableExtension.swift @@ -1,10 +1,8 @@ import Foundation extension Encodable { - func jsonString(pretty: Bool = false) throws -> 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/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 037a620..ab82946 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 { + public 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") } } @@ -157,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) @@ -171,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 { @@ -196,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 { 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/Tests/OMSWalletTests/ActiveWalletSessionTests.swift b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift new file mode 100644 index 0000000..acddbb4 --- /dev/null +++ b/Tests/OMSWalletTests/ActiveWalletSessionTests.swift @@ -0,0 +1,307 @@ +import Foundation +import Testing +@testable import OMSWallet + +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 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") + + 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 == "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"}"#, + #"{"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)) + + #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.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) + #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", + 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 { + 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 == "2026-01-01T00:00:00Z") + #expect(fixture.transport.requestCount(for: WaasAPI.UseWallet.urlPath) == 0) +} 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.. IndexerClient { let configuration = URLSessionConfiguration.ephemeral @@ -544,7 +681,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( @@ -614,7 +752,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) @@ -648,30 +786,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 78ad243..318ef0a 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,10 @@ 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(storedCredentials?.wallet.id == firstWallet.id) + #expect(storedCredentials?.wallet.address == firstWallet.address) #expect(isEmailAuth(storedCredentials?.auth)) } @@ -158,7 +158,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]) } @@ -182,37 +183,110 @@ private func isOidcAuth( for: WaasAPI.UseWallet.urlPath ) - try await fixture.client.startEmailAuth(email: "user@example.com", sessionLifetimeSeconds: 30) + try await fixture.client.startEmailAuth(email: "user@example.com", sessionLifetimeSeconds: 15_552_000) _ = try await fixture.client.completeEmailAuth(code: "123456") let completeAuthRequest = try fixture.transport.decodedRequest( CompleteAuthRequest.self, for: WaasAPI.CompleteAuth.urlPath ) - #expect(completeAuthRequest.lifetime == 30) - #expect(isEmailAuth(fixture.client.session.auth)) + #expect(completeAuthRequest.lifetime == 15_552_000) + #expect(isEmailAuth(fixture.client.session?.auth)) } -@Test func TestWalletStartEmailAuthRejectsInvalidSessionLifetimeBeforeRequestOrSignOut() async throws { - let fixture = makeMockWalletClient() +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(15_552_001)]) +func TestWalletAuthEntryPointsRejectInvalidSessionLifetimeBeforeRequestOrSignOut( + _ entryPoint: SessionLifetimeEntryPoint, + sessionLifetimeSeconds: UInt32 +) async throws { + #expect(WalletClient.defaultSessionLifetimeSeconds == 604_800) + #expect(WalletClient.maxSessionLifetimeSeconds == 15_552_000) + + 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" + ) + 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 + ) + started = try await fixture.client.startOIDCRedirectAuth(provider: provider) + } 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) - #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.localizedDescription == "sessionLifetimeSeconds must be an integer between 1 and 2592000") - } catch { - #expect(Bool(false)) + #expect(error.operation == entryPoint.operation) + #expect(error.localizedDescription == "sessionLifetimeSeconds must be an integer between 1 and 15552000") } - #expect(fixture.transport.requestCount(for: WaasAPI.CommitVerifier.urlPath) == 0) - #expect(fixture.client.walletId == wallet.id) - #expect(fixture.client.walletAddress == 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 { @@ -319,6 +393,8 @@ private func isOidcAuth( canceledExpiredEvent = event } canceledObservation.cancel() + // A server without GetCredential keeps local expiry authoritative. + fixture.transport.enqueueWebRPCBadRoute(for: WaasAPI.GetCredential.urlPath) now = Date(timeIntervalSince1970: 1_767_225_601) do { @@ -332,25 +408,25 @@ private func isOidcAuth( _ = try await waitForSessionExpiredEvent { secondExpiredEvent } let storedCredentials = try fixture.storedCredentials() - #expect(expiredEvent?.session.walletAddress == wallet.address) - #expect(expiredEvent?.session.expiresAt == Date(timeIntervalSince1970: 1_767_225_600)) + #expect(expiredEvent?.wallet?.address == wallet.address) + #expect(expiredEvent?.session.expiresAt == expiresAt) #expect(isEmailAuth(expiredEvent?.session.auth)) - #expect(expiredEvent?.expiredAt == Date(timeIntervalSince1970: 1_767_225_600)) - #expect(secondExpiredEvent?.session.walletAddress == wallet.address) + #expect(expiredEvent?.expiredAt == expiresAt) + #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) + #expect(fixture.transport.requestCount(for: WaasAPI.GetCredential.urlPath) == 1) #expect(fixture.transport.requestCount(for: WaasAPI.SignMessage.urlPath) == 0) } @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,25 +444,21 @@ 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(replayedEvent?.expiredAt == "2026-01-01T00:00:00Z") + #expect(try fixture.storedCredentials()?.wallet.id == storedCredentials.wallet.id) #expect(fixture.signer.clearCallCount == 1) } @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() 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 +491,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 +507,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( @@ -471,9 +542,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( @@ -490,21 +564,29 @@ private func waitForSessionExpiredEvent( UseWalletResponse(wallet: wallet), for: WaasAPI.UseWallet.urlPath ) + fixture.transport.enqueueWebRPCBadRoute(for: WaasAPI.GetCredential.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?.session.walletAddress == wallet.address) - #expect(isEmailAuth(replayedEvent?.session.auth)) - #expect(fixture.client.session == OMSWalletSessionState(walletAddress: nil)) - #expect(try fixture.storedCredentials()?.walletId == wallet.id) + _ = 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) } @Test func TestWalletCompleteEmailAuthFetchesPaginatedWallets() async throws { @@ -682,7 +764,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 +832,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 +862,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 +874,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 +905,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,36 +940,14 @@ 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)) } -@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: "0xdef") + let availableWallet = testWallet(id: "wallet-def", address: "0xdef0000000000000000000000000000000000000") try fixture.transport.enqueue( CommitVerifierResponse( verifier: "oidc-verifier-123", @@ -920,8 +980,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 +989,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 +1021,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 +1056,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,26 +1119,14 @@ 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) } -@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( @@ -1133,7 +1181,6 @@ private func waitForSessionExpiredEvent( providerRedirectURI: "https://provider.example/callback", authorizeParams: ["prompt": "consent"] ) - let result = try await fixture.client.startOIDCRedirectAuth( provider: provider, authorizeParams: ["prompt": "select_account", "audience": "wallet"] @@ -1178,32 +1225,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( @@ -1278,8 +1299,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 +1341,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 +1392,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 +1408,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 +1441,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 +1461,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 +1482,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 +1516,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 +1533,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 +1576,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 +1585,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 +1640,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 +1676,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 } @@ -1671,37 +1691,37 @@ private func waitForSessionExpiredEvent( #expect(fixture.oidcRedirectAuthStore.pending == nil) } -@Test func TestWalletHandleOidcRedirectCallbackRejectsInvalidSessionLifetimeBeforeRequest() async throws { - let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) +/// 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( - CommitVerifierResponse( - verifier: "oidc-verifier-123", - loginHint: "user@example.com", - challenge: "pkce-challenge" + completeAuthResponse( + identity: Identity(type: .oidc, iss: "https://issuer.example", sub: "oidc-sub-123"), + wallets: [selectedWallet] ), - for: WaasAPI.CommitVerifier.urlPath + for: WaasAPI.CompleteAuth.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" - ) + try fixture.transport.enqueue( + UseWalletResponse(wallet: selectedWallet), + for: WaasAPI.UseWallet.urlPath ) - 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") + 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(fixture.transport.requestCount(for: WaasAPI.CompleteAuth.urlPath) == 0) + #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 { @@ -1732,7 +1752,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 { @@ -1745,7 +1768,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", @@ -1763,7 +1786,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 { @@ -1781,7 +1807,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 +1845,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 +1880,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) } @@ -1917,45 +1943,29 @@ 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([:])) - } - await expectNoAuthenticatedWalletSession { - try await fixture.client.isValidMessageSignature( - network: .polygonAmoy, - walletAddress: "0xwallet", - message: "hello", - signature: "0xsig" - ) - } - await expectNoAuthenticatedWalletSession { - try await fixture.client.isValidTypedDataSignature( - network: .polygonAmoy, - walletAddress: "0xwallet", - typedData: .object([:]), - signature: "0xsig" - ) + try await fixture.client.signTypedData(network: .amoy, typedData: .object([:])) } 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", - method: "mint(address)", + network: .amoy, + contractAddress: "0xcontract", + method: "mint", args: nil ) } @@ -1966,8 +1976,6 @@ private func waitForSessionExpiredEvent( WaasAPI.RevokeAccess.urlPath, WaasAPI.SignMessage.urlPath, WaasAPI.SignTypedData.urlPath, - WaasPublicAPI.IsValidMessageSignature.urlPath, - WaasPublicAPI.IsValidTypedDataSignature.urlPath, WaasAPI.PrepareEthereumTransaction.urlPath, WaasAPI.PrepareEthereumContractCall.urlPath ] @@ -1975,32 +1983,31 @@ 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( - network: .polygonAmoy, + try await feeSelectorFixture.client.sendTransaction( + network: .amoy, to: "0xabc", value: "0", selectFeeOption: .firstAvailable ) } await expectNoAuthenticatedWalletSession { - try await walletIdOnlyFixture.client.callContract( - network: .polygonAmoy, - contract: "0xcontract", - method: "mint(address)", + try await feeSelectorFixture.client.callContract( + network: .amoy, + contractAddress: "0xcontract", + method: "mint", args: nil, 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, @@ -2038,6 +2045,17 @@ private func waitForSessionExpiredEvent( 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 @@ -2050,7 +2068,8 @@ private func waitForSessionExpiredEvent( #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) @@ -2060,58 +2079,15 @@ private func waitForSessionExpiredEvent( #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.walletId = "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 { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") let transfer = WaasGenerated.Grant( kind: .nativeTransfer, nativeTransfer: WaasGenerated.NativeTransferGrant( @@ -2195,8 +2171,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", id: "solana-wallet") try fixture.transport.enqueue( WaasGenerated.SignMessageResponse(signature: "solana-signature"), for: WaasAPI.SignMessage.urlPath @@ -2222,7 +2197,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 ) @@ -2260,8 +2235,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", id: "solana-wallet") let usdcMint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" try fixture.indexerBackend.setSolanaBalancesResponse( """ @@ -2357,8 +2331,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2380,7 +2353,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0" ) @@ -2404,8 +2377,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", id: "wallet-main") fixture.transport.enqueueCancellation(for: WaasAPI.TransactionStatusMethod.urlPath) do { @@ -2419,8 +2391,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", id: "wallet-main") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2466,7 +2437,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0", selectFeeOption: .firstAvailable @@ -2485,8 +2456,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", id: "wallet-main") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2525,7 +2495,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, to: "0xabc", value: "0", selectFeeOption: .firstAvailable @@ -2542,54 +2512,39 @@ private func waitForSessionExpiredEvent( #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 { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") fixture.indexerBackend.setNativeBalance( NativeTokenBalance( accountAddress: "0xwallet", @@ -2635,7 +2590,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) @@ -2672,8 +2627,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2688,7 +2642,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .custom { _ in nil } ) @@ -2706,8 +2660,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2728,11 +2681,12 @@ private func waitForSessionExpiredEvent( for: WaasAPI.TransactionStatusMethod.urlPath ) + let receivedOptionCounts = LockedBox<[Int]>([]) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), selectFeeOption: .custom { feeOptions in - #expect(feeOptions.isEmpty) + receivedOptionCounts.withValue { $0.append(feeOptions.count) } return nil } ) @@ -2741,6 +2695,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) @@ -2751,8 +2706,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2770,7 +2724,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 @@ -2782,8 +2736,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2798,7 +2751,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) @@ -2815,8 +2768,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2838,9 +2790,9 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.callContract( - network: .polygonAmoy, - contract: "0xcontract", - method: "mint(address)", + network: .amoy, + contractAddress: "0xcontract", + method: "mint", args: [AbiArg(type: "address", value: .string("0xrecipient"))], mode: .native ) @@ -2862,10 +2814,10 @@ private func waitForSessionExpiredEvent( #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.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2875,18 +2827,28 @@ private func waitForSessionExpiredEvent( 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: .polygonAmoy, - 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) @@ -2895,45 +2857,16 @@ private func waitForSessionExpiredEvent( #expect(fixture.transport.requestCount(for: WaasAPI.TransactionStatusMethod.urlPath) == 0) } -@Test func TestWalletCallContractCanSkipStatusPolling() async throws { +@Test(arguments: [ + WaasGenerated.TransactionStatus.pending, + .quoted, + .unknown("future-status"), +]) +func TestWalletSendTransactionMarksTimedOutWhenPollingDeadlineExpires( + status: WaasGenerated.TransactionStatus +) async throws { let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" - - 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: .polygonAmoy, - contract: "0xcontract", - method: "mint(address)", - 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 { - let fixture = makeMockWalletClient() - fixture.client.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" + fixture.client.activeWallet = activeTestWallet("0xwallet", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -2950,12 +2883,12 @@ private func waitForSessionExpiredEvent( for: WaasAPI.Execute.urlPath ) try fixture.transport.enqueue( - WaasTransactionStatusResponse(status: .pending), + WaasTransactionStatusResponse(status: status), for: WaasAPI.TransactionStatusMethod.urlPath ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), statusPolling: TransactionStatusPollingOptions( timeoutMs: 0 @@ -2963,50 +2896,12 @@ private func waitForSessionExpiredEvent( ) #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.walletId = "wallet-main" - fixture.client.walletAddress = "0xwallet" - 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: .polygonAmoy, - 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), @@ -3016,8 +2911,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( txnId: "txn-invalid-polling-\(index)", @@ -3031,7 +2925,7 @@ private func waitForSessionExpiredEvent( do { _ = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0"), statusPolling: options ) @@ -3047,8 +2941,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3070,7 +2963,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) @@ -3083,8 +2976,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3106,7 +2998,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) @@ -3119,8 +3011,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3142,7 +3033,7 @@ private func waitForSessionExpiredEvent( ) let txResult = try await fixture.client.sendTransaction( - network: .polygonAmoy, + network: .amoy, request: SendTransactionRequest(to: "0xabc", value: "0") ) @@ -3155,8 +3046,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", id: "wallet-main") try fixture.transport.enqueue( PrepareResponse( @@ -3171,7 +3061,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") @@ -3215,9 +3105,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 @@ -3246,11 +3134,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(), @@ -3258,9 +3203,12 @@ func makeMockWalletClient( oidcNonceGenerator: @escaping () throws -> 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", @@ -3346,6 +3294,8 @@ func testWallet( networkFamily = .evm case .solana: networkFamily = .solana + case .tron: + networkFamily = .tron case .unknown(let value): networkFamily = .unknown(value) } @@ -3358,6 +3308,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 +3398,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 +3421,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 +3444,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,13 +3475,25 @@ 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(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 @@ -3515,7 +3503,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 { @@ -3529,24 +3517,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() } @@ -3615,32 +3585,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 { @@ -3808,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) @@ -3842,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 @@ -3867,6 +3836,7 @@ final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { path: path, body: body ) + await gate(for: path)?.wait() switch response { case .success(let body): @@ -3918,6 +3888,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 @@ -3933,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 8fc2ad3..c19bd9e 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.walletAddress = "0x1111111111111111111111111111111111111111" + fixture.client.activeWallet = activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main") await expectPublicError( try await fixture.client.getWalletImportRecipientKey(cipherSuite: .p256Sha256Aes256Gcm), @@ -90,6 +89,52 @@ import Testing ) } +@Test func TestPublicErrorContractsWalletImportAddressAlreadyImported() async throws { + let importTransport = MockWaasTransport() + let fixture = makeMockWalletClient( + walletImportClient: WaasClient(baseURL: "https://wallet-import.test", transport: importTransport) + ) + 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 TestPublicErrorContractsWaasHttpAndBadResponsesHaveUpstreamDetails() async throws { let httpFixture = makeRestoredWalletClient() httpFixture.transport.enqueueRawHTTPError( @@ -200,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" ) ) @@ -322,9 +367,10 @@ 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", 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"), @@ -348,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 @@ -428,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" ) ) @@ -656,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( @@ -769,6 +972,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, @@ -857,46 +1110,9 @@ 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.walletId = "wallet-main" - fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" - fixture.client.sessionExpiresAt = "2099-01-01T00:00:00Z" + activateTestSession(fixture, wallet: activeTestWallet("0x1111111111111111111111111111111111111111", id: "wallet-main")) return fixture } diff --git a/Tests/OMSWalletTests/RequestsTests.swift b/Tests/OMSWalletTests/RequestsTests.swift index 866e506..13a2e6e 100644 --- a/Tests/OMSWalletTests/RequestsTests.swift +++ b/Tests/OMSWalletTests/RequestsTests.swift @@ -26,39 +26,42 @@ 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)) -} - -@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]) + #expect(OidcIdToken.handleHash(idToken) == "eFZOodqrVYyRrQXoMyRVFyFSAj4PsRNgR8ktA8AaeUI") +} + +@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)) } } -@Test func TestP256RawSignatureDerRoundTrip() throws { +@Test func TestP256DerSignatureConvertsToRaw() throws { let expectedRawSignature = Array(repeating: UInt8(0), count: 31) + [UInt8(0x80)] + Array(repeating: UInt8(0), count: 31) @@ -97,33 +100,42 @@ 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 session = OMSWalletSession( + expiresAt: "2026-01-01T00:00:00Z", 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(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) } @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") @@ -139,7 +151,7 @@ import Testing ) } -@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 new file mode 100644 index 0000000..0251137 --- /dev/null +++ b/Tests/OMSWalletTests/SignatureVerificationTests.swift @@ -0,0 +1,219 @@ +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 { + try enqueueResponse(transport, isValid: true) + } + + func enqueueResponse(_ transport: MockWaasTransport, isValid: Bool) throws { + if isTypedData { + try transport.enqueue(IsValidTypedDataSignatureResponse(isValid: isValid), for: path) + } else { + try transport.enqueue(IsValidMessageSignatureResponse(isValid: isValid), 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) { + activateTestSession(fixture, wallet: Wallet(id: "wallet-active", type: type, address: address, keyOrigin: .enclave)) +} + +@Test(arguments: SignatureVerificationMethod.allCases, [true, false]) +func TestSignatureVerificationWithAddressWorksWhileSignedOut( + _ method: SignatureVerificationMethod, + expectedIsValid: Bool +) async throws { + let fixture = makeMockWalletClient() + 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 == expectedIsValid) + #expect(body == method.expectedBody(walletAddress: method.address)) +} + +@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)) +} + +@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) +} + +@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/Tests/OMSWalletTests/TronWalletTests.swift b/Tests/OMSWalletTests/TronWalletTests.swift new file mode 100644 index 0000000..07fe2d5 --- /dev/null +++ b/Tests/OMSWalletTests/TronWalletTests.swift @@ -0,0 +1,486 @@ +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 func makeActiveWalletFixture( + type: WalletType = .tron, + address: String = tronWalletAddress +) -> MockWalletClientFixture { + let fixture = makeMockWalletClient() + activateTestSession(fixture, wallet: Wallet(id: "wallet-id", type: type, address: address, keyOrigin: .enclave)) + 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(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, + contractAddress: 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, + contractAddress: 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, + contractAddress: "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, + contractAddress: 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, + contractAddress: 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(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, contractAddress: 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, contractAddress: 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.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) +} diff --git a/Tests/OMSWalletTests/WaasGeneratedAliases.swift b/Tests/OMSWalletTests/WaasGeneratedAliases.swift index ac68955..c7e69f5 100644 --- a/Tests/OMSWalletTests/WaasGeneratedAliases.swift +++ b/Tests/OMSWalletTests/WaasGeneratedAliases.swift @@ -1,53 +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 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 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, *) diff --git a/Tests/OMSWalletTests/WalletImportHPKETests.swift b/Tests/OMSWalletTests/WalletImportHPKETests.swift new file mode 100644 index 0000000..1e350fb --- /dev/null +++ b/Tests/OMSWalletTests/WalletImportHPKETests.swift @@ -0,0 +1,102 @@ +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()) + } +} 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, diff --git a/docs/error-contracts.md b/docs/error-contracts.md index ac1ab24..0893e36 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. @@ -35,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. @@ -62,14 +66,20 @@ 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` | -| 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` | +| 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` | +| 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` | 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/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, 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. diff --git a/scripts/api-presentation.json b/scripts/api-presentation.json index 239f764..250272e 100644 --- a/scripts/api-presentation.json +++ b/scripts/api-presentation.json @@ -14,13 +14,16 @@ "label": "Authentication and sessions", "symbols": [ { - "path": "WalletClient.walletAddress" + "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" @@ -173,7 +176,7 @@ "path": "OIDCRedirectAuthResult" }, { - "path": "OMSWalletSessionState" + "path": "OMSWalletSession" }, { "path": "OMSWalletSessionAuth" @@ -210,6 +213,12 @@ { "path": "WalletClient.signTypedData(network:typedData:)" }, + { + "path": "WalletClient.signTronMessage(message:)" + }, + { + "path": "WalletClient.signTronTypedData(typedData:)" + }, { "path": "WalletClient.isValidMessageSignature(network:walletAddress:message:signature:)" }, @@ -219,6 +228,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:)" }, @@ -229,7 +244,13 @@ "path": "WalletClient.sendSolanaTransfer(network:asset:to:amount:selectFeeOption:mode:waitForStatus:statusPolling:)" }, { - "path": "WalletClient.callContract(network:contract:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)" + "path": "WalletClient.sendTronTransaction(network:to:value:data:selectFeeOption:waitForStatus:statusPolling:)" + }, + { + "path": "WalletClient.callContract(network:contractAddress:method:args:selectFeeOption:mode:waitForStatus:statusPolling:)" + }, + { + "path": "WalletClient.callTronContract(network:contractAddress:method:args:selectFeeOption:waitForStatus:statusPolling:)" }, { "path": "WalletClient.getTransactionStatus(txnId:)" @@ -317,6 +338,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 +439,12 @@ { "path": "SolanaNetworks" }, + { + "path": "TronNetwork" + }, + { + "path": "TronNetworks" + }, { "path": "JSONValue" }, 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" diff --git a/scripts/public-api-baseline.txt b/scripts/public-api-baseline.txt index 8209d5b..8a3608f 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 @@ -243,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 @@ -267,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 @@ -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 @@ -331,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 @@ -345,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 @@ -363,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 @@ -371,11 +410,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 +431,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 +441,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 +486,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 +505,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,9 +514,10 @@ 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 +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) @@ -483,6 +540,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 +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: String, 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: 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) @@ -546,8 +604,16 @@ 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 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) @@ -555,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(walletAddress:wallet:) init(walletAddress: String, 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 +629,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 @@ -572,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 @@ -582,21 +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.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.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 @@ -605,15 +674,18 @@ 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.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.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 = 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 @@ -630,7 +702,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 +777,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? @@ -718,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 } @@ -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: String 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.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? symbol swift.property OMSWalletUpstreamError.message let message: String? symbol swift.property OMSWalletUpstreamError.name let name: String? @@ -782,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 @@ -915,23 +1004,69 @@ 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 +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.walletId var walletId: String { get set } +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 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 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 +1083,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 +1092,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,19 +1118,26 @@ 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 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 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.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