From b833b304d58b767843c3793ce2a8f58804c5da4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20Gr=C3=BCning?= Date: Fri, 12 Jun 2026 13:01:51 +0200 Subject: [PATCH 1/3] feat(wallet): nullable walletAddress, fixed invalid fee option, add configurable transaction status polling --- API.md | 43 ++++-- README.md | 46 +++++- Sources/Swift SDK/Clients/WalletClient.swift | 24 +-- .../Swift SDK/Clients/WalletOperations.swift | 130 +++++++++++----- .../Swift SDK/Clients/WalletSessions.swift | 6 +- .../Swift SDK/Models/FeeOptionsSelector.swift | 21 ++- .../Swift SDK/Models/TransactionResult.swift | 20 +++ Tests/Swift SDKTests/MockWalletTests.swift | 143 +++++++++++++++--- 8 files changed, 335 insertions(+), 98 deletions(-) diff --git a/API.md b/API.md index 53f153f..b54f5a7 100644 --- a/API.md +++ b/API.md @@ -99,10 +99,10 @@ Most apps create a wallet client through `OMSClient`. Use this initializer only ### walletAddress ```swift -var walletAddress: String +var walletAddress: String? ``` -The read-only on-chain address of the active wallet. Empty until a wallet is restored or activated by `completeEmailAuth`, `useWallet`, or `createWallet`. +The read-only on-chain address of the active wallet, or `nil` until a wallet is restored or activated by `completeEmailAuth`, `useWallet`, or `createWallet`. ### walletId @@ -410,7 +410,9 @@ func sendTransaction( to: String, value: String, selectFeeOption: FeeOptionSelector? = nil, - mode: TransactionMode = .relayer + mode: TransactionMode = .relayer, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse ``` @@ -432,7 +434,9 @@ Full-parameter overload: func sendTransaction( network: Network, request: SendTransactionRequest, - selectFeeOption: FeeOptionSelector? = nil + selectFeeOption: FeeOptionSelector? = nil, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse ``` @@ -445,7 +449,9 @@ func callContract( method: String, args: [AbiArg]?, selectFeeOption: FeeOptionSelector? = nil, - mode: TransactionMode = .relayer + mode: TransactionMode = .relayer, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse ``` @@ -530,9 +536,11 @@ func getTokenBalances( Fetches token balances for a wallet on a supported network. Omit `contractAddress` to list balances across contracts. Use `page` to request later pages or a custom page size. ```swift +guard let walletAddress = oms.wallet.walletAddress else { return } + let result = try await oms.indexer.getTokenBalances( network: .polygon, - walletAddress: oms.wallet.walletAddress, + walletAddress: walletAddress, includeMetadata: true, page: TokenBalancesPageRequest(page: 1, pageSize: 100) ) @@ -550,9 +558,11 @@ func getNativeTokenBalance( Fetches the native token balance for a wallet on a supported network. Returns `nil` when the indexer response does not include a balance object. ```swift +guard let walletAddress = oms.wallet.walletAddress else { return } + let balance = try await oms.indexer.getNativeTokenBalance( network: .polygon, - walletAddress: oms.wallet.walletAddress + walletAddress: walletAddress ) ``` @@ -760,7 +770,7 @@ fee option when the transaction is sponsored. | Selector | Description | |---|---| -| `.firstAvailable` | Uses indexer balances to skip underfunded fee options and picks the first option the wallet can pay. | +| `.firstAvailable` | Uses indexer balances to skip underfunded fee options and picks the first option the wallet can pay. Malformed balance or fee values are treated as not payable. | | `.custom { options in ... }` | Calls your closure with the full `[FeeOptionWithBalance]` list and expects a `FeeOptionSelection?`. | ```swift @@ -902,6 +912,21 @@ The transaction flow returns as soon as status is `.executed` or a non-empty `txnHash` is available. `TransactionResult` remains available as a compatibility alias. +### TransactionStatusPollingOptions + +```swift +struct TransactionStatusPollingOptions { + let timeoutMs: UInt64? + let intervalMs: UInt64? + let fastIntervalMs: UInt64? + let fastPollCount: Int? +} +``` + +Controls how `sendTransaction` and `callContract` poll WaaS transaction status +after execute when `waitForStatus` is `true`. Defaults are a 60 second timeout, +400 ms fast polling for the first status checks, then 2 second polling. + ### TransactionMode ```swift @@ -937,7 +962,7 @@ struct SendTransactionRequest { } ``` -Used with the full `sendTransaction(network:request:selectFeeOption:)` overload. +Used with the full `sendTransaction(network:request:selectFeeOption:waitForStatus:statusPolling:)` overload. `mode` defaults to `.relayer`. ### TokenBalancesResult diff --git a/README.md b/README.md index 9af6f6e..130b757 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,37 @@ print("Transaction status:", txResult.status) print("Transaction hash:", txResult.txnHash ?? "pending") ``` +To return immediately after execute without status polling, pass +`waitForStatus: false`. You can then call `getTransactionStatus` with the +returned `txnId`. + +```swift +let value = try parseUnits(value: "1", decimals: 18) +let txResult = try await oms.wallet.sendTransaction( + network: .polygon, + to: "0xRecipient", + value: value, + waitForStatus: false +) + +let status = try await oms.wallet.getTransactionStatus(txnId: txResult.txnId) +``` + +To tune polling, pass `statusPolling`: + +```swift +let value = try parseUnits(value: "1", decimals: 18) +let txResult = try await oms.wallet.sendTransaction( + network: .polygon, + to: "0xRecipient", + value: value, + statusPolling: TransactionStatusPollingOptions( + timeoutMs: 30_000, + intervalMs: 1_000 + ) +) +``` + Provide `selectFeeOption` on `sendTransaction` or `callContract` to choose from the returned fee options: @@ -335,9 +366,11 @@ let signature = try await oms.wallet.signMessage( ### Verify a Message Signature ```swift +guard let walletAddress = oms.wallet.walletAddress else { return } + let isValid = try await oms.wallet.isValidMessageSignature( network: .polygon, - walletAddress: oms.wallet.walletAddress, + walletAddress: walletAddress, message: "Hello from OMS", signature: signature ) @@ -370,10 +403,11 @@ let signature = try await oms.wallet.signTypedData( network: .polygon, typedData: typedData ) +guard let walletAddress = oms.wallet.walletAddress else { return } let isValid = try await oms.wallet.isValidTypedDataSignature( network: .polygon, - walletAddress: oms.wallet.walletAddress, + walletAddress: walletAddress, typedData: typedData, signature: signature ) @@ -441,9 +475,11 @@ do { ### Query Token Balances ```swift +guard let walletAddress = oms.wallet.walletAddress else { return } + let result = try await oms.indexer.getTokenBalances( network: .polygon, - walletAddress: oms.wallet.walletAddress, + walletAddress: walletAddress, includeMetadata: true, page: TokenBalancesPageRequest(page: 0, pageSize: 100) ) @@ -457,9 +493,11 @@ for balance in result.balances { ### Query Native Token Balance ```swift +guard let walletAddress = oms.wallet.walletAddress else { return } + let balance = try await oms.indexer.getNativeTokenBalance( network: .polygon, - walletAddress: oms.wallet.walletAddress + walletAddress: walletAddress ) print(balance?.balance ?? "0") diff --git a/Sources/Swift SDK/Clients/WalletClient.swift b/Sources/Swift SDK/Clients/WalletClient.swift index 4611e76..9fcd4d9 100644 --- a/Sources/Swift SDK/Clients/WalletClient.swift +++ b/Sources/Swift SDK/Clients/WalletClient.swift @@ -46,8 +46,10 @@ public class WalletClient: @unchecked Sendable { ) private static let defaultSessionLifetimeSeconds: UInt32 = 604_800 - private static let defaultTransactionPollingIntervals: [UInt64] = - Array(repeating: 750_000_000, count: 5) + Array(repeating: 2_000_000_000, count: 28) + static let defaultTransactionStatusPollTimeoutMs: UInt64 = 60_000 + static let defaultFastTransactionStatusPollIntervalMs: UInt64 = 400 + static let defaultFastTransactionStatusPollCount = 5 + static let defaultTransactionStatusPollIntervalMs: UInt64 = 2_000 private let sessionLock = NSRecursiveLock() private var _signedClient: WaasWalletClient @@ -61,7 +63,6 @@ public class WalletClient: @unchecked Sendable { } var publicClient: WaasWalletPublicClient let indexerClient: any WalletIndexerClient - let transactionPollingIntervals: [UInt64] let projectId: String let publishableKey: String @@ -131,12 +132,15 @@ public class WalletClient: @unchecked Sendable { private var _verifier = "" private var _challenge = "" - public internal(set) var walletAddress: String { + public internal(set) var walletAddress: String? { get { - withSessionLock { _walletAddress } + withSessionLock { + let walletAddress = _walletAddress.trimmingCharacters(in: .whitespacesAndNewlines) + return walletAddress.isEmpty ? nil : walletAddress + } } set { - withSessionLock { _walletAddress = newValue } + withSessionLock { _walletAddress = newValue ?? "" } } } public internal(set) var walletId: String { @@ -197,7 +201,6 @@ public class WalletClient: @unchecked Sendable { ) } self.signedClientFactory = makeSignedClient - self.transactionPollingIntervals = Self.defaultTransactionPollingIntervals self._walletId = "" self._walletAddress = "" @@ -229,7 +232,6 @@ public class WalletClient: @unchecked Sendable { oidcRedirectAuthStore: (any OidcRedirectAuthStore)? = nil, oidcNonceGenerator: @escaping () throws -> String = OidcRedirectAuth.generateNonce, signedClientFactory: ((any CredentialSigner) -> WaasWalletClient)? = nil, - transactionPollingIntervals: [UInt64]? = nil, currentDate: @escaping () -> Date = Date.init ) { self.projectId = projectId @@ -241,7 +243,6 @@ public class WalletClient: @unchecked Sendable { self.currentDate = currentDate let makeSignedClient = signedClientFactory ?? { _ in signedClient } self.signedClientFactory = makeSignedClient - self.transactionPollingIntervals = transactionPollingIntervals ?? Self.defaultTransactionPollingIntervals self._walletId = "" self._walletAddress = "" @@ -296,14 +297,13 @@ public class WalletClient: @unchecked Sendable { } func requireActiveWalletAddress() throws -> String { - let walletAddress = walletAddress.trimmingCharacters(in: .whitespacesAndNewlines) - guard !walletAddress.isEmpty else { + guard let walletAddress else { throw OmsSdkError.sessionMissing() } return walletAddress } - func activeWalletAddressIfNeeded(for selectFeeOption: FeeOptionSelector?) throws -> String? { + func walletAddressIfNeeded(for selectFeeOption: FeeOptionSelector?) throws -> String? { guard selectFeeOption != nil else { return nil } diff --git a/Sources/Swift SDK/Clients/WalletOperations.swift b/Sources/Swift SDK/Clients/WalletOperations.swift index d5eb4b4..bd52d7a 100644 --- a/Sources/Swift SDK/Clients/WalletOperations.swift +++ b/Sources/Swift SDK/Clients/WalletOperations.swift @@ -85,11 +85,13 @@ extension WalletClient { to: String, value: String, selectFeeOption: FeeOptionSelector? = nil, - mode: TransactionMode = .relayer + mode: TransactionMode = .relayer, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { try await runOmsOperation(.walletSendTransaction) { let walletId = try requireActiveWalletId() - let walletAddress = try activeWalletAddressIfNeeded(for: selectFeeOption) + let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) return try await sendTransaction( network: network, request: SendTransactionRequest( @@ -99,6 +101,8 @@ extension WalletClient { mode: mode ), selectFeeOption: selectFeeOption, + waitForStatus: waitForStatus, + statusPolling: statusPolling, walletId: walletId, walletAddress: walletAddress ) @@ -108,15 +112,19 @@ extension WalletClient { public func sendTransaction( network: Network, request: SendTransactionRequest, - selectFeeOption: FeeOptionSelector? = nil + selectFeeOption: FeeOptionSelector? = nil, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { try await runOmsOperation(.walletSendTransaction) { let walletId = try requireActiveWalletId() - let walletAddress = try activeWalletAddressIfNeeded(for: selectFeeOption) + let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) return try await sendTransaction( network: network, request: request, selectFeeOption: selectFeeOption, + waitForStatus: waitForStatus, + statusPolling: statusPolling, walletId: walletId, walletAddress: walletAddress ) @@ -127,6 +135,8 @@ extension WalletClient { network: Network, request: SendTransactionRequest, selectFeeOption: FeeOptionSelector?, + waitForStatus: Bool, + statusPolling: TransactionStatusPollingOptions, walletId: String, walletAddress: String? ) async throws -> SendTransactionResponse { @@ -145,8 +155,10 @@ extension WalletClient { network: network, prepareResponse: prepareResponse, feeOptionSelector: selectFeeOption, + waitForStatus: waitForStatus, + statusPolling: statusPolling, walletAddress: walletAddress - ); + ) } public func callContract( @@ -155,11 +167,13 @@ extension WalletClient { method: String, args: [AbiArg]?, selectFeeOption: FeeOptionSelector? = nil, - mode: TransactionMode = .relayer + mode: TransactionMode = .relayer, + waitForStatus: Bool = true, + statusPolling: TransactionStatusPollingOptions = TransactionStatusPollingOptions() ) async throws -> SendTransactionResponse { try await runOmsOperation(.walletCallContract) { let walletId = try requireActiveWalletId() - let walletAddress = try activeWalletAddressIfNeeded(for: selectFeeOption) + let walletAddress = try walletAddressIfNeeded(for: selectFeeOption) let prepareResponse = try await signedClient.prepareEthereumContractCall( PrepareEthereumContractCallRequest( network: network.chainId, @@ -175,8 +189,10 @@ extension WalletClient { network: network, prepareResponse: prepareResponse, feeOptionSelector: selectFeeOption, + waitForStatus: waitForStatus, + statusPolling: statusPolling, walletAddress: walletAddress - ); + ) } } @@ -209,6 +225,8 @@ extension WalletClient { network: Network, prepareResponse: PrepareResponse, feeOptionSelector: FeeOptionSelector?, + waitForStatus: Bool, + statusPolling: TransactionStatusPollingOptions, walletAddress: String? ) async throws -> SendTransactionResponse { let feeOptionSelection = try await selectFeeOption( @@ -224,32 +242,26 @@ extension WalletClient { ) let executeResponse = try await signedClient.execute(executeRequest) - var response = SendTransactionResponse( - txnId: prepareResponse.txnId, - status: executeResponse.status - ) - if response.status == .executed { - return try await getSubmittedTransactionResult(txnId: prepareResponse.txnId) - } - - for pollIntervalNanos in transactionPollingIntervals { - guard response.status == .pending else { - break - } - if pollIntervalNanos > 0 { - try await Task.sleep(nanoseconds: pollIntervalNanos) - } - - let statusResponse = try await getTransactionStatus(txnId: prepareResponse.txnId) - response = SendTransactionResponse( + if !waitForStatus { + return SendTransactionResponse( txnId: prepareResponse.txnId, - status: statusResponse.status, - txnHash: statusResponse.txnHash + status: executeResponse.status ) + } - if isSubmittedTransactionResult(response) { - return response - } + let statusResponse = try await waitForTransactionStatus( + txnId: prepareResponse.txnId, + fallbackStatus: executeResponse.status, + options: statusPolling + ) + let response = SendTransactionResponse( + txnId: prepareResponse.txnId, + status: statusResponse.status, + txnHash: statusResponse.txnHash + ) + + if isSubmittedTransactionResult(response) { + return response } if response.status == .pending { @@ -391,19 +403,55 @@ extension WalletClient { } } - private func getSubmittedTransactionResult(txnId: String) async throws -> SendTransactionResponse { - let statusResponse = try await getTransactionStatus(txnId: txnId) - let response = SendTransactionResponse( - txnId: txnId, - status: statusResponse.status, - txnHash: statusResponse.txnHash - ) + private func waitForTransactionStatus( + txnId: String, + fallbackStatus: TransactionStatus, + options: TransactionStatusPollingOptions + ) async throws -> TransactionStatusResponse { + let timeoutMs = options.timeoutMs ?? Self.defaultTransactionStatusPollTimeoutMs + let deadlineMs = currentTimeMs() + Double(timeoutMs) + var lastStatus = TransactionStatusResponse(status: fallbackStatus) + var completedPolls = 0 + + while true { + lastStatus = try await getTransactionStatus(txnId: txnId) + completedPolls += 1 + + if lastStatus.status == .executed || hasTransactionHash(lastStatus.txnHash) { + return lastStatus + } - guard isSubmittedTransactionResult(response) else { - throw TransactionError.transactionFailed(status: statusResponse.status) + let pollDelayMs = transactionStatusPollDelayMs( + completedPolls: completedPolls, + options: options + ) + if pollDelayMs == 0 { + return lastStatus + } + + let remainingMs = deadlineMs - currentTimeMs() + if remainingMs <= 0 { + return lastStatus + } + + let sleepMs = min(Double(pollDelayMs), remainingMs) + try await Task.sleep(nanoseconds: UInt64(sleepMs * 1_000_000)) } + } + + private func transactionStatusPollDelayMs( + completedPolls: Int, + options: TransactionStatusPollingOptions + ) -> UInt64 { + let fastPollCount = options.fastPollCount ?? Self.defaultFastTransactionStatusPollCount + if completedPolls < fastPollCount { + return options.fastIntervalMs ?? Self.defaultFastTransactionStatusPollIntervalMs + } + return options.intervalMs ?? Self.defaultTransactionStatusPollIntervalMs + } - return response + private func currentTimeMs() -> Double { + currentDate().timeIntervalSince1970 * 1_000 } private func isSubmittedTransactionResult(_ response: SendTransactionResponse) -> Bool { diff --git a/Sources/Swift SDK/Clients/WalletSessions.swift b/Sources/Swift SDK/Clients/WalletSessions.swift index 63ddb19..bfb22f3 100644 --- a/Sources/Swift SDK/Clients/WalletSessions.swift +++ b/Sources/Swift SDK/Clients/WalletSessions.swift @@ -69,7 +69,7 @@ extension WalletClient { } private func currentSessionLocked() -> SessionState { - guard !walletAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard let walletAddress else { return SessionState(walletAddress: nil) } @@ -97,7 +97,7 @@ extension WalletClient { sessionExpiryTask = nil activePendingWalletSelection = nil try? credentialSession.clearSignerKeepingCredentials() - walletAddress = "" + walletAddress = nil walletId = "" verifier = "" challenge = "" @@ -224,7 +224,7 @@ extension WalletClient { sessionExpiryTask = nil activePendingWalletSelection = nil try credentialSession.clear() - walletAddress = "" + walletAddress = nil walletId = "" verifier = "" challenge = "" diff --git a/Sources/Swift SDK/Models/FeeOptionsSelector.swift b/Sources/Swift SDK/Models/FeeOptionsSelector.swift index b556643..b172462 100644 --- a/Sources/Swift SDK/Models/FeeOptionsSelector.swift +++ b/Sources/Swift SDK/Models/FeeOptionsSelector.swift @@ -64,13 +64,12 @@ public struct FeeOptionSelector: Sendable { @available(macOS 12.0, iOS 15.0, *) public extension FeeOptionSelector { static let firstAvailable = FeeOptionSelector { options in - options.filter { option in + options.first { option in guard let availableRaw = option.availableRaw else { return false } - return !isNumericValueLessThan(availableRaw, option.feeOption.value) - } - .first? + return hasEnoughBalance(availableRaw, feeValue: option.feeOption.value) + }? .selection } @@ -78,17 +77,17 @@ public extension FeeOptionSelector { FeeOptionSelector(pick) } - private static func isNumericValueLessThan(_ lhs: String, _ rhs: String) -> Bool { - guard let normalizedLhs = normalizedUnsignedDecimal(lhs), - let normalizedRhs = normalizedUnsignedDecimal(rhs) else { - return lhs < rhs + private static func hasEnoughBalance(_ availableRaw: String, feeValue: String) -> Bool { + guard let available = normalizedUnsignedDecimal(availableRaw), + let fee = normalizedUnsignedDecimal(feeValue) else { + return false } - if normalizedLhs.count != normalizedRhs.count { - return normalizedLhs.count < normalizedRhs.count + if available.count != fee.count { + return available.count > fee.count } - return normalizedLhs < normalizedRhs + return available >= fee } private static func normalizedUnsignedDecimal(_ value: String) -> String? { diff --git a/Sources/Swift SDK/Models/TransactionResult.swift b/Sources/Swift SDK/Models/TransactionResult.swift index 3779013..8bc3052 100644 --- a/Sources/Swift SDK/Models/TransactionResult.swift +++ b/Sources/Swift SDK/Models/TransactionResult.swift @@ -1,3 +1,23 @@ +@available(macOS 12.0, iOS 15.0, *) +public struct TransactionStatusPollingOptions: Sendable, Equatable { + public let timeoutMs: UInt64? + public let intervalMs: UInt64? + public let fastIntervalMs: UInt64? + public let fastPollCount: Int? + + public init( + timeoutMs: UInt64? = nil, + intervalMs: UInt64? = nil, + fastIntervalMs: UInt64? = nil, + fastPollCount: Int? = nil + ) { + self.timeoutMs = timeoutMs + self.intervalMs = intervalMs + self.fastIntervalMs = fastIntervalMs + self.fastPollCount = fastPollCount + } +} + @available(macOS 12.0, iOS 15.0, *) public struct SendTransactionResponse: Codable, Sendable, Equatable { public let txnId: String diff --git a/Tests/Swift SDKTests/MockWalletTests.swift b/Tests/Swift SDKTests/MockWalletTests.swift index aca1259..1a09598 100644 --- a/Tests/Swift SDKTests/MockWalletTests.swift +++ b/Tests/Swift SDKTests/MockWalletTests.swift @@ -36,7 +36,7 @@ import Testing #expect(request.verifier == "verifier") #expect(request.lifetime == 604_800) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.client.session.walletAddress == nil) #expect(fixture.transport.requestCount(for: WaasWalletAPI.UseWallet.urlPath) == 0) #expect(fixture.transport.requestCount(for: WaasWalletAPI.CreateWallet.urlPath) == 0) @@ -411,7 +411,7 @@ import Testing #expect(error.operation == nil) #expect(fixture.transport.requestCount(for: WaasWalletAPI.CreateWallet.urlPath) == 0) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(try fixture.storedCredentials() == nil) } catch { #expect(Bool(false)) @@ -510,7 +510,7 @@ import Testing } #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.client.verifier == "") #expect(fixture.client.challenge == "") #expect(fixture.client.session == SessionState(walletAddress: nil)) @@ -630,7 +630,7 @@ import Testing #expect(pendingSelection.wallets.map(\.id) == [availableWallet.id]) #expect(pendingSelection.credential.credentialId == testCredential.credentialId) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.client.session.walletAddress == nil) #expect(try fixture.storedCredentials() == nil) #expect(fixture.transport.requestCount(for: WaasWalletAPI.UseWallet.urlPath) == 0) @@ -717,7 +717,7 @@ import Testing } #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.client.verifier == "") #expect(fixture.client.challenge == "") #expect(fixture.client.session == SessionState(walletAddress: nil)) @@ -982,7 +982,7 @@ import Testing #expect(pendingSelection.wallets.map(\.id) == ["wallet-def"]) #expect(pendingSelection.credential.credentialId == testCredential.credentialId) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.client.session.walletAddress == nil) #expect(fixture.client.canResumeOidcRedirectAuth == false) #expect(fixture.oidcRedirectAuthStore.pending == nil) @@ -1095,7 +1095,7 @@ import Testing #expect(fixture.client.canResumeOidcRedirectAuth) #expect(fixture.oidcRedirectAuthStore.pending?.verifier == "oidc-verifier-123") #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.signer.clearCallCount == 1) #expect(fixture.signer.hasStoredCredential) } @@ -1131,7 +1131,7 @@ import Testing #expect(error.operation == .walletHandleOidcRedirectCallback) #expect(error.underlyingError as? OidcRedirectAuthError == .providerError("User cancelled")) #expect(fixture.client.walletId == "") - #expect(fixture.client.walletAddress == "") + #expect(fixture.client.walletAddress == nil) #expect(fixture.client.canResumeOidcRedirectAuth == false) #expect(fixture.oidcRedirectAuthStore.pending == nil) } @@ -1533,6 +1533,50 @@ import Testing #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 { + 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 selection = try await FeeOptionSelector.firstAvailable([ + FeeOptionWithBalance( + feeOption: feeOption, + availableRaw: "000100" + ) + ]) + + #expect(selection?.token == FeeOptionWithBalance(feeOption: feeOption).selection.token) +} + @Test func TestWalletSendTransactionCustomSelectorReceivesFeeOptionBalances() async throws { let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" @@ -1747,13 +1791,13 @@ import Testing #expect(executeRequest.feeOption?.token == "POL") } -@Test func TestWalletSendTransactionReturnsPendingResponseAfterPollingTimeout() async throws { - let fixture = makeMockWalletClient(transactionPollingIntervals: [0, 0]) +@Test func TestWalletSendTransactionCanSkipStatusPolling() async throws { + let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" try fixture.transport.enqueue( PrepareResponse( - txnId: "txn-pending-1", + txnId: "txn-no-poll-1", status: .quoted, feeOptions: [], sponsored: true, @@ -1765,9 +1809,69 @@ import Testing ExecuteResponse(status: .pending), for: WaasWalletAPI.Execute.urlPath ) + + let txResult = try await fixture.client.sendTransaction( + network: .polygonAmoy, + request: SendTransactionRequest(to: "0xabc", value: "0"), + waitForStatus: false + ) + + #expect(txResult.txnId == "txn-no-poll-1") + #expect(txResult.status == .pending) + #expect(txResult.txnHash == nil) + #expect(fixture.transport.requestCount(for: WaasWalletAPI.TransactionStatus.urlPath) == 0) +} + +@Test func TestWalletCallContractCanSkipStatusPolling() async throws { + let fixture = makeMockWalletClient() + fixture.client.walletId = "wallet-main" + try fixture.transport.enqueue( - TransactionStatusResponse(status: .pending), - for: WaasWalletAPI.TransactionStatus.urlPath + PrepareResponse( + txnId: "txn-contract-no-poll-1", + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasWalletAPI.PrepareEthereumContractCall.urlPath + ) + try fixture.transport.enqueue( + ExecuteResponse(status: .pending), + for: WaasWalletAPI.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(fixture.transport.requestCount(for: WaasWalletAPI.TransactionStatus.urlPath) == 0) +} + +@Test func TestWalletSendTransactionReturnsPendingResponseWhenPollingDelayIsZero() async throws { + let fixture = makeMockWalletClient() + fixture.client.walletId = "wallet-main" + + try fixture.transport.enqueue( + PrepareResponse( + txnId: "txn-pending-1", + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasWalletAPI.PrepareEthereumTransaction.urlPath + ) + try fixture.transport.enqueue( + ExecuteResponse(status: .pending), + for: WaasWalletAPI.Execute.urlPath ) try fixture.transport.enqueue( TransactionStatusResponse(status: .pending), @@ -1776,17 +1880,22 @@ import Testing let txResult = try await fixture.client.sendTransaction( network: .polygonAmoy, - request: SendTransactionRequest(to: "0xabc", value: "0") + request: SendTransactionRequest(to: "0xabc", value: "0"), + statusPolling: TransactionStatusPollingOptions( + intervalMs: 0, + fastIntervalMs: 0, + fastPollCount: 0 + ) ) #expect(txResult.txnId == "txn-pending-1") #expect(txResult.status == .pending) #expect(txResult.txnHash == nil) - #expect(fixture.transport.requestCount(for: WaasWalletAPI.TransactionStatus.urlPath) == 2) + #expect(fixture.transport.requestCount(for: WaasWalletAPI.TransactionStatus.urlPath) == 1) } @Test func TestWalletSendTransactionReturnsWhenPollingFindsHashBeforeExecuted() async throws { - let fixture = makeMockWalletClient(transactionPollingIntervals: [0, 0]) + let fixture = makeMockWalletClient() fixture.client.walletId = "wallet-main" try fixture.transport.enqueue( @@ -1953,7 +2062,6 @@ private func makeMockWalletClient( signer: MockCredentialSigner = MockCredentialSigner(), oidcRedirectAuthStore: InMemoryOidcRedirectAuthStore = InMemoryOidcRedirectAuthStore(), oidcNonceGenerator: @escaping () throws -> String = OidcRedirectAuth.generateNonce, - transactionPollingIntervals: [UInt64]? = nil, currentDate: @escaping () -> Date = Date.init, storedCredentials: StorableCredentials? = nil ) -> MockWalletClientFixture { @@ -1990,7 +2098,6 @@ private func makeMockWalletClient( indexerClient: indexerClient, oidcRedirectAuthStore: oidcRedirectAuthStore, oidcNonceGenerator: oidcNonceGenerator, - transactionPollingIntervals: transactionPollingIntervals, currentDate: currentDate ) client.verifier = "verifier" From b59e49d8a9b92a696cb25de960f401fba418e01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20Gr=C3=BCning?= Date: Fri, 12 Jun 2026 13:20:45 +0200 Subject: [PATCH 2/3] refactor: reorganize and separate SDK model files by domain --- Sources/Swift SDK/Clients/IndexerClient.swift | 425 ------------------ Sources/Swift SDK/Clients/WalletClient.swift | 38 -- .../Models/Auth/CompleteAuthResult.swift | 48 ++ .../Models/{ => Auth}/OMSClientIdentity.swift | 0 .../Models/{ => Auth}/OidcRedirectAuth.swift | 0 .../Models/Auth/PendingWalletSelection.swift | 38 ++ .../Models/Auth/WalletActivationResult.swift | 12 + .../Models/Auth/WalletSelectionBehavior.swift | 7 + .../Models/Indexer/TokenBalance.swift | 109 +++++ .../Models/Indexer/TokenBalancesPage.swift | 11 + .../Indexer/TokenBalancesPageRequest.swift | 9 + .../Models/Indexer/TokenBalancesResult.swift | 11 + .../Models/Indexer/TokenContractInfo.swift | 48 ++ .../Models/Indexer/TokenMetadata.swift | 145 ++++++ .../Models/Indexer/TokenMetadataAsset.swift | 85 ++++ .../{ => Operations}/FeeOptionsSelector.swift | 0 .../SendTransactionRequest.swift | 0 .../Models/Operations/TransactionError.swift | 26 ++ .../{ => Operations}/TransactionResult.swift | 0 .../PendingWalletSelectionSession.swift | 8 + .../{ => Sessions}/SessionLoginType.swift | 0 .../Models/Sessions/SessionMetadata.swift | 5 + .../Models/{ => Sessions}/SessionState.swift | 0 .../{ => Sessions}/StorableCredentials.swift | 0 .../Swift SDK/Models/WalletAuthResult.swift | 102 ----- 25 files changed, 562 insertions(+), 565 deletions(-) create mode 100644 Sources/Swift SDK/Models/Auth/CompleteAuthResult.swift rename Sources/Swift SDK/Models/{ => Auth}/OMSClientIdentity.swift (100%) rename Sources/Swift SDK/Models/{ => Auth}/OidcRedirectAuth.swift (100%) create mode 100644 Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift create mode 100644 Sources/Swift SDK/Models/Auth/WalletActivationResult.swift create mode 100644 Sources/Swift SDK/Models/Auth/WalletSelectionBehavior.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenBalance.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenBalancesPage.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenBalancesPageRequest.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenBalancesResult.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenContractInfo.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenMetadata.swift create mode 100644 Sources/Swift SDK/Models/Indexer/TokenMetadataAsset.swift rename Sources/Swift SDK/Models/{ => Operations}/FeeOptionsSelector.swift (100%) rename Sources/Swift SDK/Models/{ => Operations}/SendTransactionRequest.swift (100%) create mode 100644 Sources/Swift SDK/Models/Operations/TransactionError.swift rename Sources/Swift SDK/Models/{ => Operations}/TransactionResult.swift (100%) create mode 100644 Sources/Swift SDK/Models/Sessions/PendingWalletSelectionSession.swift rename Sources/Swift SDK/Models/{ => Sessions}/SessionLoginType.swift (100%) create mode 100644 Sources/Swift SDK/Models/Sessions/SessionMetadata.swift rename Sources/Swift SDK/Models/{ => Sessions}/SessionState.swift (100%) rename Sources/Swift SDK/Models/{ => Sessions}/StorableCredentials.swift (100%) delete mode 100644 Sources/Swift SDK/Models/WalletAuthResult.swift diff --git a/Sources/Swift SDK/Clients/IndexerClient.swift b/Sources/Swift SDK/Clients/IndexerClient.swift index 7177e59..f84d8d5 100644 --- a/Sources/Swift SDK/Clients/IndexerClient.swift +++ b/Sources/Swift SDK/Clients/IndexerClient.swift @@ -1,430 +1,5 @@ import Foundation -public struct TokenBalancesPage: Codable, Sendable { - public let page: Int - public let pageSize: Int - public let more: Bool - - public init(page: Int, pageSize: Int, more: Bool) { - self.page = page - self.pageSize = pageSize - self.more = more - } -} - -public struct TokenBalancesPageRequest: Codable, Sendable { - public let page: Int? - public let pageSize: Int? - - public init(page: Int? = nil, pageSize: Int? = nil) { - self.page = page - self.pageSize = pageSize - } -} - -public struct TokenContractInfo: Codable, Sendable { - public let chainId: Int64? - public let address: String? - public let source: String? - public let name: String? - public let type: String? - public let symbol: String? - public let decimals: Int? - public let logoURI: String? - public let deployed: Bool? - public let bytecodeHash: String? - public let extensions: [String: WebRPCJSONValue]? - public let updatedAt: String? - public let queuedAt: String? - public let status: String? - - public init( - chainId: Int64? = nil, - address: String? = nil, - source: String? = nil, - name: String? = nil, - type: String? = nil, - symbol: String? = nil, - decimals: Int? = nil, - logoURI: String? = nil, - deployed: Bool? = nil, - bytecodeHash: String? = nil, - extensions: [String: WebRPCJSONValue]? = nil, - updatedAt: String? = nil, - queuedAt: String? = nil, - status: String? = nil - ) { - self.chainId = chainId - self.address = address - self.source = source - self.name = name - self.type = type - self.symbol = symbol - self.decimals = decimals - self.logoURI = logoURI - self.deployed = deployed - self.bytecodeHash = bytecodeHash - self.extensions = extensions - self.updatedAt = updatedAt - self.queuedAt = queuedAt - self.status = status - } -} - -public struct TokenMetadataAsset: Codable, Sendable { - public let id: Int64? - public let collectionId: Int64? - public let tokenId: String? - public let url: String? - public let metadataField: String? - public let name: String? - public let filesize: Int64? - public let mimeType: String? - public let width: Int? - public let height: Int? - public let updatedAt: String? - - public init( - id: Int64? = nil, - collectionId: Int64? = nil, - tokenId: String? = nil, - url: String? = nil, - metadataField: String? = nil, - name: String? = nil, - filesize: Int64? = nil, - mimeType: String? = nil, - width: Int? = nil, - height: Int? = nil, - updatedAt: String? = nil - ) { - self.id = id - self.collectionId = collectionId - self.tokenId = tokenId - self.url = url - self.metadataField = metadataField - self.name = name - self.filesize = filesize - self.mimeType = mimeType - self.width = width - self.height = height - self.updatedAt = updatedAt - } - - enum CodingKeys: String, CodingKey { - case id - case collectionId - case tokenId - case tokenID - case url - case metadataField - case name - case filesize - case mimeType - case width - case height - case updatedAt - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - 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) - self.filesize = try container.decodeIfPresent(Int64.self, forKey: .filesize) - self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) - self.width = try container.decodeIfPresent(Int.self, forKey: .width) - self.height = try container.decodeIfPresent(Int.self, forKey: .height) - self.updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) - } - - public func encode(to encoder: Encoder) throws { - 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(url, forKey: .url) - try container.encodeIfPresent(metadataField, forKey: .metadataField) - try container.encodeIfPresent(name, forKey: .name) - try container.encodeIfPresent(filesize, forKey: .filesize) - try container.encodeIfPresent(mimeType, forKey: .mimeType) - try container.encodeIfPresent(width, forKey: .width) - try container.encodeIfPresent(height, forKey: .height) - try container.encodeIfPresent(updatedAt, forKey: .updatedAt) - } -} - -public struct TokenMetadata: Codable, Sendable { - public let chainId: Int64? - public let contractAddress: String? - public let tokenId: String? - public let source: String? - public let name: String? - public let description: String? - public let image: String? - public let video: String? - public let audio: String? - public let properties: [String: WebRPCJSONValue]? - public let attributes: [[String: WebRPCJSONValue]]? - public let imageData: String? - public let externalUrl: String? - public let backgroundColor: String? - public let animationUrl: String? - public let decimals: Int? - public let updatedAt: String? - public let assets: [TokenMetadataAsset]? - public let status: String? - public let queuedAt: String? - public let lastFetched: String? - - public init( - chainId: Int64? = nil, - contractAddress: String? = nil, - tokenId: String? = nil, - source: String? = nil, - name: String? = nil, - description: String? = nil, - image: String? = nil, - video: String? = nil, - audio: String? = nil, - properties: [String: WebRPCJSONValue]? = nil, - attributes: [[String: WebRPCJSONValue]]? = nil, - imageData: String? = nil, - externalUrl: String? = nil, - backgroundColor: String? = nil, - animationUrl: String? = nil, - decimals: Int? = nil, - updatedAt: String? = nil, - assets: [TokenMetadataAsset]? = nil, - status: String? = nil, - queuedAt: String? = nil, - lastFetched: String? = nil - ) { - self.chainId = chainId - self.contractAddress = contractAddress - self.tokenId = tokenId - self.source = source - self.name = name - self.description = description - self.image = image - self.video = video - self.audio = audio - self.properties = properties - self.attributes = attributes - self.imageData = imageData - self.externalUrl = externalUrl - self.backgroundColor = backgroundColor - self.animationUrl = animationUrl - self.decimals = decimals - self.updatedAt = updatedAt - self.assets = assets - self.status = status - self.queuedAt = queuedAt - self.lastFetched = lastFetched - } - - enum CodingKeys: String, CodingKey { - case chainId - case contractAddress - case tokenId - case tokenID - case source - case name - case description - case image - case video - case audio - case properties - case attributes - case imageData = "image_data" - case externalUrl = "external_url" - case backgroundColor = "background_color" - case animationUrl = "animation_url" - case decimals - case updatedAt - case assets - case status - case queuedAt - case lastFetched - } - - public init(from decoder: Decoder) throws { - 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) - self.tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) - ?? container.decodeIfPresent(String.self, forKey: .tokenID) - self.source = try container.decodeIfPresent(String.self, forKey: .source) - self.name = try container.decodeIfPresent(String.self, forKey: .name) - self.description = try container.decodeIfPresent(String.self, forKey: .description) - self.image = try container.decodeIfPresent(String.self, forKey: .image) - self.video = try container.decodeIfPresent(String.self, forKey: .video) - self.audio = try container.decodeIfPresent(String.self, forKey: .audio) - self.properties = try container.decodeIfPresent([String: WebRPCJSONValue].self, forKey: .properties) - self.attributes = try container.decodeIfPresent([[String: WebRPCJSONValue]].self, forKey: .attributes) - self.imageData = try container.decodeIfPresent(String.self, forKey: .imageData) - self.externalUrl = try container.decodeIfPresent(String.self, forKey: .externalUrl) - self.backgroundColor = try container.decodeIfPresent(String.self, forKey: .backgroundColor) - self.animationUrl = try container.decodeIfPresent(String.self, forKey: .animationUrl) - self.decimals = try container.decodeIfPresent(Int.self, forKey: .decimals) - self.updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) - self.assets = try container.decodeIfPresent([TokenMetadataAsset].self, forKey: .assets) - self.status = try container.decodeIfPresent(String.self, forKey: .status) - self.queuedAt = try container.decodeIfPresent(String.self, forKey: .queuedAt) - self.lastFetched = try container.decodeIfPresent(String.self, forKey: .lastFetched) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(chainId, forKey: .chainId) - try container.encodeIfPresent(contractAddress, forKey: .contractAddress) - try container.encodeIfPresent(tokenId, forKey: .tokenID) - try container.encodeIfPresent(source, forKey: .source) - try container.encodeIfPresent(name, forKey: .name) - try container.encodeIfPresent(description, forKey: .description) - try container.encodeIfPresent(image, forKey: .image) - try container.encodeIfPresent(video, forKey: .video) - try container.encodeIfPresent(audio, forKey: .audio) - try container.encodeIfPresent(properties, forKey: .properties) - try container.encodeIfPresent(attributes, forKey: .attributes) - try container.encodeIfPresent(imageData, forKey: .imageData) - try container.encodeIfPresent(externalUrl, forKey: .externalUrl) - try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor) - try container.encodeIfPresent(animationUrl, forKey: .animationUrl) - try container.encodeIfPresent(decimals, forKey: .decimals) - try container.encodeIfPresent(updatedAt, forKey: .updatedAt) - try container.encodeIfPresent(assets, forKey: .assets) - try container.encodeIfPresent(status, forKey: .status) - try container.encodeIfPresent(queuedAt, forKey: .queuedAt) - try container.encodeIfPresent(lastFetched, forKey: .lastFetched) - } -} - -public struct TokenBalance: Codable, Sendable { - public let contractType: String? - public let contractAddress: String? - public let accountAddress: String? - public let tokenId: String? - public let balance: String? - public let balanceUSD: String? - public let priceUSD: String? - public let priceUpdatedAt: String? - public let blockHash: String? - public let blockNumber: Int64? - public let chainId: Int64? - public let uniqueCollectibles: String? - public let isSummary: Bool? - public let contractInfo: TokenContractInfo? - public let tokenMetadata: TokenMetadata? - - public init( - contractType: String?, - contractAddress: String?, - accountAddress: String?, - tokenId: String?, - balance: String?, - balanceUSD: String? = nil, - priceUSD: String? = nil, - priceUpdatedAt: String? = nil, - blockHash: String?, - blockNumber: Int64?, - chainId: Int64?, - uniqueCollectibles: String? = nil, - isSummary: Bool? = nil, - contractInfo: TokenContractInfo? = nil, - tokenMetadata: TokenMetadata? = nil - ) { - self.contractType = contractType - self.contractAddress = contractAddress - self.accountAddress = accountAddress - self.tokenId = tokenId - self.balance = balance - self.balanceUSD = balanceUSD - self.priceUSD = priceUSD - self.priceUpdatedAt = priceUpdatedAt - self.blockHash = blockHash - self.blockNumber = blockNumber - self.chainId = chainId - self.uniqueCollectibles = uniqueCollectibles - self.isSummary = isSummary - self.contractInfo = contractInfo - self.tokenMetadata = tokenMetadata - } - - enum CodingKeys: String, CodingKey { - case contractType - case contractAddress - case accountAddress - case tokenId - case tokenID - case balance - case balanceUSD - case priceUSD - case priceUpdatedAt - case blockHash - case blockNumber - case chainId - case uniqueCollectibles - case isSummary - case contractInfo - case tokenMetadata - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.contractType = try container.decodeIfPresent(String.self, forKey: .contractType) - self.contractAddress = try container.decodeIfPresent(String.self, forKey: .contractAddress) - self.accountAddress = try container.decodeIfPresent(String.self, forKey: .accountAddress) - self.tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) - ?? container.decodeIfPresent(String.self, forKey: .tokenID) - self.balance = try container.decodeIfPresent(String.self, forKey: .balance) - self.balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) - self.priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) - self.priceUpdatedAt = try container.decodeIfPresent(String.self, forKey: .priceUpdatedAt) - self.blockHash = try container.decodeIfPresent(String.self, forKey: .blockHash) - self.blockNumber = try container.decodeIfPresent(Int64.self, forKey: .blockNumber) - self.chainId = try container.decodeIfPresent(Int64.self, forKey: .chainId) - self.uniqueCollectibles = try container.decodeIfPresent(String.self, forKey: .uniqueCollectibles) - self.isSummary = try container.decodeIfPresent(Bool.self, forKey: .isSummary) - self.contractInfo = try container.decodeIfPresent(TokenContractInfo.self, forKey: .contractInfo) - self.tokenMetadata = try container.decodeIfPresent(TokenMetadata.self, forKey: .tokenMetadata) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(contractType, forKey: .contractType) - try container.encodeIfPresent(contractAddress, forKey: .contractAddress) - try container.encodeIfPresent(accountAddress, forKey: .accountAddress) - try container.encodeIfPresent(tokenId, forKey: .tokenID) - try container.encodeIfPresent(balance, forKey: .balance) - try container.encodeIfPresent(balanceUSD, forKey: .balanceUSD) - try container.encodeIfPresent(priceUSD, forKey: .priceUSD) - try container.encodeIfPresent(priceUpdatedAt, forKey: .priceUpdatedAt) - try container.encodeIfPresent(blockHash, forKey: .blockHash) - try container.encodeIfPresent(blockNumber, forKey: .blockNumber) - try container.encodeIfPresent(chainId, forKey: .chainId) - try container.encodeIfPresent(uniqueCollectibles, forKey: .uniqueCollectibles) - try container.encodeIfPresent(isSummary, forKey: .isSummary) - try container.encodeIfPresent(contractInfo, forKey: .contractInfo) - try container.encodeIfPresent(tokenMetadata, forKey: .tokenMetadata) - } -} - -public struct TokenBalancesResult: Sendable { - public let status: Int - public let page: TokenBalancesPage? - public let balances: [TokenBalance] - - public init(status: Int, page: TokenBalancesPage?, balances: [TokenBalance]) { - self.status = status - self.page = page - self.balances = balances - } -} - private struct TokenBalancesPayload: Codable { let page: TokenBalancesPage? let balances: [TokenBalance]? diff --git a/Sources/Swift SDK/Clients/WalletClient.swift b/Sources/Swift SDK/Clients/WalletClient.swift index 9fcd4d9..1dce5ef 100644 --- a/Sources/Swift SDK/Clients/WalletClient.swift +++ b/Sources/Swift SDK/Clients/WalletClient.swift @@ -1,45 +1,7 @@ import Foundation -public enum TransactionError: Error { - case noFeeOptionsAvailable - case noFeeOptionSelected - case missingTransactionHash - case transactionFailed(status: TransactionStatus) - case pollingTimedOut -} - -extension TransactionError: LocalizedError { - public var errorDescription: String? { - switch self { - case .noFeeOptionsAvailable: - 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." - } - } -} - @available(macOS 12.0, iOS 15.0, *) public class WalletClient: @unchecked Sendable { - struct SessionMetadata { - let expiresAt: String? - let loginType: SessionLoginType? - let sessionEmail: String? - } - - struct PendingWalletSelectionSession { - let id: UUID - let signerCredentialId: String - let signerKeyType: SigningAlgorithm - let metadata: SessionMetadata - } - typealias SessionExpiredNotification = ( handler: ((SessionExpiredEvent) -> Void)?, event: SessionExpiredEvent diff --git a/Sources/Swift SDK/Models/Auth/CompleteAuthResult.swift b/Sources/Swift SDK/Models/Auth/CompleteAuthResult.swift new file mode 100644 index 0000000..21a043d --- /dev/null +++ b/Sources/Swift SDK/Models/Auth/CompleteAuthResult.swift @@ -0,0 +1,48 @@ +import Foundation + +@available(macOS 12.0, iOS 15.0, *) +public enum CompleteAuthResult: Sendable { + case walletSelected( + walletAddress: String, + wallet: Wallet, + wallets: [Wallet], + credential: CredentialInfo + ) + case walletSelection(PendingWalletSelection) + + public var wallets: [Wallet] { + switch self { + case .walletSelected(_, _, let wallets, _): + return wallets + case .walletSelection(let pendingSelection): + return pendingSelection.wallets + } + } + + public var credential: CredentialInfo { + switch self { + 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, _, _): + return wallet + case .walletSelection: + return nil + } + } +} diff --git a/Sources/Swift SDK/Models/OMSClientIdentity.swift b/Sources/Swift SDK/Models/Auth/OMSClientIdentity.swift similarity index 100% rename from Sources/Swift SDK/Models/OMSClientIdentity.swift rename to Sources/Swift SDK/Models/Auth/OMSClientIdentity.swift diff --git a/Sources/Swift SDK/Models/OidcRedirectAuth.swift b/Sources/Swift SDK/Models/Auth/OidcRedirectAuth.swift similarity index 100% rename from Sources/Swift SDK/Models/OidcRedirectAuth.swift rename to Sources/Swift SDK/Models/Auth/OidcRedirectAuth.swift diff --git a/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift b/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift new file mode 100644 index 0000000..014fec8 --- /dev/null +++ b/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift @@ -0,0 +1,38 @@ +import Foundation + +@available(macOS 12.0, iOS 15.0, *) +public final class PendingWalletSelection: @unchecked Sendable { + public let walletType: WalletType + public let wallets: [Wallet] + public let credential: CredentialInfo + + private let selectWalletAction: (String) async throws -> WalletActivationResult + private let createAndSelectWalletAction: (String?) async throws -> WalletActivationResult + + init( + walletType: WalletType, + wallets: [Wallet], + credential: CredentialInfo, + selectWalletAction: @escaping (String) async throws -> WalletActivationResult, + createAndSelectWalletAction: @escaping (String?) async throws -> WalletActivationResult + ) { + self.walletType = walletType + self.wallets = wallets + self.credential = credential + self.selectWalletAction = selectWalletAction + self.createAndSelectWalletAction = createAndSelectWalletAction + } + + @discardableResult + public func selectWallet(walletId: String) async throws -> WalletActivationResult { + guard wallets.contains(where: { $0.id == walletId }) else { + throw OmsSdkError.walletSelectionUnavailable() + } + return try await selectWalletAction(walletId) + } + + @discardableResult + public func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult { + try await createAndSelectWalletAction(reference) + } +} diff --git a/Sources/Swift SDK/Models/Auth/WalletActivationResult.swift b/Sources/Swift SDK/Models/Auth/WalletActivationResult.swift new file mode 100644 index 0000000..47e0db6 --- /dev/null +++ b/Sources/Swift SDK/Models/Auth/WalletActivationResult.swift @@ -0,0 +1,12 @@ +import Foundation + +@available(macOS 12.0, iOS 15.0, *) +public struct WalletActivationResult: 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/Swift SDK/Models/Auth/WalletSelectionBehavior.swift b/Sources/Swift SDK/Models/Auth/WalletSelectionBehavior.swift new file mode 100644 index 0000000..3843ef3 --- /dev/null +++ b/Sources/Swift SDK/Models/Auth/WalletSelectionBehavior.swift @@ -0,0 +1,7 @@ +import Foundation + +@available(macOS 12.0, iOS 15.0, *) +public enum WalletSelectionBehavior: Equatable, Sendable { + case automatic + case manual +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenBalance.swift b/Sources/Swift SDK/Models/Indexer/TokenBalance.swift new file mode 100644 index 0000000..3151e48 --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenBalance.swift @@ -0,0 +1,109 @@ +public struct TokenBalance: Codable, Sendable { + public let contractType: String? + public let contractAddress: String? + public let accountAddress: String? + public let tokenId: String? + public let balance: String? + public let balanceUSD: String? + public let priceUSD: String? + public let priceUpdatedAt: String? + public let blockHash: String? + public let blockNumber: Int64? + public let chainId: Int64? + public let uniqueCollectibles: String? + public let isSummary: Bool? + public let contractInfo: TokenContractInfo? + public let tokenMetadata: TokenMetadata? + + public init( + contractType: String?, + contractAddress: String?, + accountAddress: String?, + tokenId: String?, + balance: String?, + balanceUSD: String? = nil, + priceUSD: String? = nil, + priceUpdatedAt: String? = nil, + blockHash: String?, + blockNumber: Int64?, + chainId: Int64?, + uniqueCollectibles: String? = nil, + isSummary: Bool? = nil, + contractInfo: TokenContractInfo? = nil, + tokenMetadata: TokenMetadata? = nil + ) { + self.contractType = contractType + self.contractAddress = contractAddress + self.accountAddress = accountAddress + self.tokenId = tokenId + self.balance = balance + self.balanceUSD = balanceUSD + self.priceUSD = priceUSD + self.priceUpdatedAt = priceUpdatedAt + self.blockHash = blockHash + self.blockNumber = blockNumber + self.chainId = chainId + self.uniqueCollectibles = uniqueCollectibles + self.isSummary = isSummary + self.contractInfo = contractInfo + self.tokenMetadata = tokenMetadata + } + + enum CodingKeys: String, CodingKey { + case contractType + case contractAddress + case accountAddress + case tokenId + case tokenID + case balance + case balanceUSD + case priceUSD + case priceUpdatedAt + case blockHash + case blockNumber + case chainId + case uniqueCollectibles + case isSummary + case contractInfo + case tokenMetadata + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.contractType = try container.decodeIfPresent(String.self, forKey: .contractType) + self.contractAddress = try container.decodeIfPresent(String.self, forKey: .contractAddress) + self.accountAddress = try container.decodeIfPresent(String.self, forKey: .accountAddress) + self.tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) + ?? container.decodeIfPresent(String.self, forKey: .tokenID) + self.balance = try container.decodeIfPresent(String.self, forKey: .balance) + self.balanceUSD = try container.decodeIfPresent(String.self, forKey: .balanceUSD) + self.priceUSD = try container.decodeIfPresent(String.self, forKey: .priceUSD) + self.priceUpdatedAt = try container.decodeIfPresent(String.self, forKey: .priceUpdatedAt) + self.blockHash = try container.decodeIfPresent(String.self, forKey: .blockHash) + self.blockNumber = try container.decodeIfPresent(Int64.self, forKey: .blockNumber) + self.chainId = try container.decodeIfPresent(Int64.self, forKey: .chainId) + self.uniqueCollectibles = try container.decodeIfPresent(String.self, forKey: .uniqueCollectibles) + self.isSummary = try container.decodeIfPresent(Bool.self, forKey: .isSummary) + self.contractInfo = try container.decodeIfPresent(TokenContractInfo.self, forKey: .contractInfo) + self.tokenMetadata = try container.decodeIfPresent(TokenMetadata.self, forKey: .tokenMetadata) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(contractType, forKey: .contractType) + try container.encodeIfPresent(contractAddress, forKey: .contractAddress) + try container.encodeIfPresent(accountAddress, forKey: .accountAddress) + try container.encodeIfPresent(tokenId, forKey: .tokenID) + try container.encodeIfPresent(balance, forKey: .balance) + try container.encodeIfPresent(balanceUSD, forKey: .balanceUSD) + try container.encodeIfPresent(priceUSD, forKey: .priceUSD) + try container.encodeIfPresent(priceUpdatedAt, forKey: .priceUpdatedAt) + try container.encodeIfPresent(blockHash, forKey: .blockHash) + try container.encodeIfPresent(blockNumber, forKey: .blockNumber) + try container.encodeIfPresent(chainId, forKey: .chainId) + try container.encodeIfPresent(uniqueCollectibles, forKey: .uniqueCollectibles) + try container.encodeIfPresent(isSummary, forKey: .isSummary) + try container.encodeIfPresent(contractInfo, forKey: .contractInfo) + try container.encodeIfPresent(tokenMetadata, forKey: .tokenMetadata) + } +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenBalancesPage.swift b/Sources/Swift SDK/Models/Indexer/TokenBalancesPage.swift new file mode 100644 index 0000000..2854edc --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenBalancesPage.swift @@ -0,0 +1,11 @@ +public struct TokenBalancesPage: Codable, Sendable { + public let page: Int + public let pageSize: Int + public let more: Bool + + public init(page: Int, pageSize: Int, more: Bool) { + self.page = page + self.pageSize = pageSize + self.more = more + } +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenBalancesPageRequest.swift b/Sources/Swift SDK/Models/Indexer/TokenBalancesPageRequest.swift new file mode 100644 index 0000000..b304a51 --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenBalancesPageRequest.swift @@ -0,0 +1,9 @@ +public struct TokenBalancesPageRequest: Codable, Sendable { + public let page: Int? + public let pageSize: Int? + + public init(page: Int? = nil, pageSize: Int? = nil) { + self.page = page + self.pageSize = pageSize + } +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenBalancesResult.swift b/Sources/Swift SDK/Models/Indexer/TokenBalancesResult.swift new file mode 100644 index 0000000..310f1b5 --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenBalancesResult.swift @@ -0,0 +1,11 @@ +public struct TokenBalancesResult: Sendable { + public let status: Int + public let page: TokenBalancesPage? + public let balances: [TokenBalance] + + public init(status: Int, page: TokenBalancesPage?, balances: [TokenBalance]) { + self.status = status + self.page = page + self.balances = balances + } +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenContractInfo.swift b/Sources/Swift SDK/Models/Indexer/TokenContractInfo.swift new file mode 100644 index 0000000..92ffda2 --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenContractInfo.swift @@ -0,0 +1,48 @@ +public struct TokenContractInfo: Codable, Sendable { + public let chainId: Int64? + public let address: String? + public let source: String? + public let name: String? + public let type: String? + public let symbol: String? + public let decimals: Int? + public let logoURI: String? + public let deployed: Bool? + public let bytecodeHash: String? + public let extensions: [String: WebRPCJSONValue]? + public let updatedAt: String? + public let queuedAt: String? + public let status: String? + + public init( + chainId: Int64? = nil, + address: String? = nil, + source: String? = nil, + name: String? = nil, + type: String? = nil, + symbol: String? = nil, + decimals: Int? = nil, + logoURI: String? = nil, + deployed: Bool? = nil, + bytecodeHash: String? = nil, + extensions: [String: WebRPCJSONValue]? = nil, + updatedAt: String? = nil, + queuedAt: String? = nil, + status: String? = nil + ) { + self.chainId = chainId + self.address = address + self.source = source + self.name = name + self.type = type + self.symbol = symbol + self.decimals = decimals + self.logoURI = logoURI + self.deployed = deployed + self.bytecodeHash = bytecodeHash + self.extensions = extensions + self.updatedAt = updatedAt + self.queuedAt = queuedAt + self.status = status + } +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenMetadata.swift b/Sources/Swift SDK/Models/Indexer/TokenMetadata.swift new file mode 100644 index 0000000..461faef --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenMetadata.swift @@ -0,0 +1,145 @@ +public struct TokenMetadata: Codable, Sendable { + public let chainId: Int64? + public let contractAddress: String? + public let tokenId: String? + public let source: String? + public let name: String? + public let description: String? + public let image: String? + public let video: String? + public let audio: String? + public let properties: [String: WebRPCJSONValue]? + public let attributes: [[String: WebRPCJSONValue]]? + public let imageData: String? + public let externalUrl: String? + public let backgroundColor: String? + public let animationUrl: String? + public let decimals: Int? + public let updatedAt: String? + public let assets: [TokenMetadataAsset]? + public let status: String? + public let queuedAt: String? + public let lastFetched: String? + + public init( + chainId: Int64? = nil, + contractAddress: String? = nil, + tokenId: String? = nil, + source: String? = nil, + name: String? = nil, + description: String? = nil, + image: String? = nil, + video: String? = nil, + audio: String? = nil, + properties: [String: WebRPCJSONValue]? = nil, + attributes: [[String: WebRPCJSONValue]]? = nil, + imageData: String? = nil, + externalUrl: String? = nil, + backgroundColor: String? = nil, + animationUrl: String? = nil, + decimals: Int? = nil, + updatedAt: String? = nil, + assets: [TokenMetadataAsset]? = nil, + status: String? = nil, + queuedAt: String? = nil, + lastFetched: String? = nil + ) { + self.chainId = chainId + self.contractAddress = contractAddress + self.tokenId = tokenId + self.source = source + self.name = name + self.description = description + self.image = image + self.video = video + self.audio = audio + self.properties = properties + self.attributes = attributes + self.imageData = imageData + self.externalUrl = externalUrl + self.backgroundColor = backgroundColor + self.animationUrl = animationUrl + self.decimals = decimals + self.updatedAt = updatedAt + self.assets = assets + self.status = status + self.queuedAt = queuedAt + self.lastFetched = lastFetched + } + + enum CodingKeys: String, CodingKey { + case chainId + case contractAddress + case tokenId + case tokenID + case source + case name + case description + case image + case video + case audio + case properties + case attributes + case imageData = "image_data" + case externalUrl = "external_url" + case backgroundColor = "background_color" + case animationUrl = "animation_url" + case decimals + case updatedAt + case assets + case status + case queuedAt + case lastFetched + } + + public init(from decoder: Decoder) throws { + 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) + self.tokenId = try container.decodeIfPresent(String.self, forKey: .tokenId) + ?? container.decodeIfPresent(String.self, forKey: .tokenID) + self.source = try container.decodeIfPresent(String.self, forKey: .source) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.description = try container.decodeIfPresent(String.self, forKey: .description) + self.image = try container.decodeIfPresent(String.self, forKey: .image) + self.video = try container.decodeIfPresent(String.self, forKey: .video) + self.audio = try container.decodeIfPresent(String.self, forKey: .audio) + self.properties = try container.decodeIfPresent([String: WebRPCJSONValue].self, forKey: .properties) + self.attributes = try container.decodeIfPresent([[String: WebRPCJSONValue]].self, forKey: .attributes) + self.imageData = try container.decodeIfPresent(String.self, forKey: .imageData) + self.externalUrl = try container.decodeIfPresent(String.self, forKey: .externalUrl) + self.backgroundColor = try container.decodeIfPresent(String.self, forKey: .backgroundColor) + self.animationUrl = try container.decodeIfPresent(String.self, forKey: .animationUrl) + self.decimals = try container.decodeIfPresent(Int.self, forKey: .decimals) + self.updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + self.assets = try container.decodeIfPresent([TokenMetadataAsset].self, forKey: .assets) + self.status = try container.decodeIfPresent(String.self, forKey: .status) + self.queuedAt = try container.decodeIfPresent(String.self, forKey: .queuedAt) + self.lastFetched = try container.decodeIfPresent(String.self, forKey: .lastFetched) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(chainId, forKey: .chainId) + try container.encodeIfPresent(contractAddress, forKey: .contractAddress) + try container.encodeIfPresent(tokenId, forKey: .tokenID) + try container.encodeIfPresent(source, forKey: .source) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(image, forKey: .image) + try container.encodeIfPresent(video, forKey: .video) + try container.encodeIfPresent(audio, forKey: .audio) + try container.encodeIfPresent(properties, forKey: .properties) + try container.encodeIfPresent(attributes, forKey: .attributes) + try container.encodeIfPresent(imageData, forKey: .imageData) + try container.encodeIfPresent(externalUrl, forKey: .externalUrl) + try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor) + try container.encodeIfPresent(animationUrl, forKey: .animationUrl) + try container.encodeIfPresent(decimals, forKey: .decimals) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(assets, forKey: .assets) + try container.encodeIfPresent(status, forKey: .status) + try container.encodeIfPresent(queuedAt, forKey: .queuedAt) + try container.encodeIfPresent(lastFetched, forKey: .lastFetched) + } +} diff --git a/Sources/Swift SDK/Models/Indexer/TokenMetadataAsset.swift b/Sources/Swift SDK/Models/Indexer/TokenMetadataAsset.swift new file mode 100644 index 0000000..32787cb --- /dev/null +++ b/Sources/Swift SDK/Models/Indexer/TokenMetadataAsset.swift @@ -0,0 +1,85 @@ +public struct TokenMetadataAsset: Codable, Sendable { + public let id: Int64? + public let collectionId: Int64? + public let tokenId: String? + public let url: String? + public let metadataField: String? + public let name: String? + public let filesize: Int64? + public let mimeType: String? + public let width: Int? + public let height: Int? + public let updatedAt: String? + + public init( + id: Int64? = nil, + collectionId: Int64? = nil, + tokenId: String? = nil, + url: String? = nil, + metadataField: String? = nil, + name: String? = nil, + filesize: Int64? = nil, + mimeType: String? = nil, + width: Int? = nil, + height: Int? = nil, + updatedAt: String? = nil + ) { + self.id = id + self.collectionId = collectionId + self.tokenId = tokenId + self.url = url + self.metadataField = metadataField + self.name = name + self.filesize = filesize + self.mimeType = mimeType + self.width = width + self.height = height + self.updatedAt = updatedAt + } + + enum CodingKeys: String, CodingKey { + case id + case collectionId + case tokenId + case tokenID + case url + case metadataField + case name + case filesize + case mimeType + case width + case height + case updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + 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) + self.filesize = try container.decodeIfPresent(Int64.self, forKey: .filesize) + self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + self.width = try container.decodeIfPresent(Int.self, forKey: .width) + self.height = try container.decodeIfPresent(Int.self, forKey: .height) + self.updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + } + + public func encode(to encoder: Encoder) throws { + 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(url, forKey: .url) + try container.encodeIfPresent(metadataField, forKey: .metadataField) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(filesize, forKey: .filesize) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(width, forKey: .width) + try container.encodeIfPresent(height, forKey: .height) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) + } +} diff --git a/Sources/Swift SDK/Models/FeeOptionsSelector.swift b/Sources/Swift SDK/Models/Operations/FeeOptionsSelector.swift similarity index 100% rename from Sources/Swift SDK/Models/FeeOptionsSelector.swift rename to Sources/Swift SDK/Models/Operations/FeeOptionsSelector.swift diff --git a/Sources/Swift SDK/Models/SendTransactionRequest.swift b/Sources/Swift SDK/Models/Operations/SendTransactionRequest.swift similarity index 100% rename from Sources/Swift SDK/Models/SendTransactionRequest.swift rename to Sources/Swift SDK/Models/Operations/SendTransactionRequest.swift diff --git a/Sources/Swift SDK/Models/Operations/TransactionError.swift b/Sources/Swift SDK/Models/Operations/TransactionError.swift new file mode 100644 index 0000000..9d78e34 --- /dev/null +++ b/Sources/Swift SDK/Models/Operations/TransactionError.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum TransactionError: Error { + case noFeeOptionsAvailable + case noFeeOptionSelected + case missingTransactionHash + case transactionFailed(status: TransactionStatus) + case pollingTimedOut +} + +extension TransactionError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noFeeOptionsAvailable: + 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." + } + } +} diff --git a/Sources/Swift SDK/Models/TransactionResult.swift b/Sources/Swift SDK/Models/Operations/TransactionResult.swift similarity index 100% rename from Sources/Swift SDK/Models/TransactionResult.swift rename to Sources/Swift SDK/Models/Operations/TransactionResult.swift diff --git a/Sources/Swift SDK/Models/Sessions/PendingWalletSelectionSession.swift b/Sources/Swift SDK/Models/Sessions/PendingWalletSelectionSession.swift new file mode 100644 index 0000000..bd19339 --- /dev/null +++ b/Sources/Swift SDK/Models/Sessions/PendingWalletSelectionSession.swift @@ -0,0 +1,8 @@ +import Foundation + +struct PendingWalletSelectionSession { + let id: UUID + let signerCredentialId: String + let signerKeyType: SigningAlgorithm + let metadata: SessionMetadata +} diff --git a/Sources/Swift SDK/Models/SessionLoginType.swift b/Sources/Swift SDK/Models/Sessions/SessionLoginType.swift similarity index 100% rename from Sources/Swift SDK/Models/SessionLoginType.swift rename to Sources/Swift SDK/Models/Sessions/SessionLoginType.swift diff --git a/Sources/Swift SDK/Models/Sessions/SessionMetadata.swift b/Sources/Swift SDK/Models/Sessions/SessionMetadata.swift new file mode 100644 index 0000000..a015317 --- /dev/null +++ b/Sources/Swift SDK/Models/Sessions/SessionMetadata.swift @@ -0,0 +1,5 @@ +struct SessionMetadata { + let expiresAt: String? + let loginType: SessionLoginType? + let sessionEmail: String? +} diff --git a/Sources/Swift SDK/Models/SessionState.swift b/Sources/Swift SDK/Models/Sessions/SessionState.swift similarity index 100% rename from Sources/Swift SDK/Models/SessionState.swift rename to Sources/Swift SDK/Models/Sessions/SessionState.swift diff --git a/Sources/Swift SDK/Models/StorableCredentials.swift b/Sources/Swift SDK/Models/Sessions/StorableCredentials.swift similarity index 100% rename from Sources/Swift SDK/Models/StorableCredentials.swift rename to Sources/Swift SDK/Models/Sessions/StorableCredentials.swift diff --git a/Sources/Swift SDK/Models/WalletAuthResult.swift b/Sources/Swift SDK/Models/WalletAuthResult.swift deleted file mode 100644 index f4e5a57..0000000 --- a/Sources/Swift SDK/Models/WalletAuthResult.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -@available(macOS 12.0, iOS 15.0, *) -public struct WalletActivationResult: Sendable { - public let walletAddress: String - public let wallet: Wallet - - public init(walletAddress: String, wallet: Wallet) { - self.walletAddress = walletAddress - self.wallet = wallet - } -} - -@available(macOS 12.0, iOS 15.0, *) -public enum WalletSelectionBehavior: Equatable, Sendable { - case automatic - case manual -} - -@available(macOS 12.0, iOS 15.0, *) -public final class PendingWalletSelection: @unchecked Sendable { - public let walletType: WalletType - public let wallets: [Wallet] - public let credential: CredentialInfo - - private let selectWalletAction: (String) async throws -> WalletActivationResult - private let createAndSelectWalletAction: (String?) async throws -> WalletActivationResult - - init( - walletType: WalletType, - wallets: [Wallet], - credential: CredentialInfo, - selectWalletAction: @escaping (String) async throws -> WalletActivationResult, - createAndSelectWalletAction: @escaping (String?) async throws -> WalletActivationResult - ) { - self.walletType = walletType - self.wallets = wallets - self.credential = credential - self.selectWalletAction = selectWalletAction - self.createAndSelectWalletAction = createAndSelectWalletAction - } - - @discardableResult - public func selectWallet(walletId: String) async throws -> WalletActivationResult { - guard wallets.contains(where: { $0.id == walletId }) else { - throw OmsSdkError.walletSelectionUnavailable() - } - return try await selectWalletAction(walletId) - } - - @discardableResult - public func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult { - try await createAndSelectWalletAction(reference) - } -} - -@available(macOS 12.0, iOS 15.0, *) -public enum CompleteAuthResult: Sendable { - case walletSelected( - walletAddress: String, - wallet: Wallet, - wallets: [Wallet], - credential: CredentialInfo - ) - case walletSelection(PendingWalletSelection) - - public var wallets: [Wallet] { - switch self { - case .walletSelected(_, _, let wallets, _): - return wallets - case .walletSelection(let pendingSelection): - return pendingSelection.wallets - } - } - - public var credential: CredentialInfo { - switch self { - 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, _, _): - return wallet - case .walletSelection: - return nil - } - } -} From e22c05d24a2661a71098aed1477c6c7f736e74fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20Gr=C3=BCning?= Date: Fri, 12 Jun 2026 13:28:27 +0200 Subject: [PATCH 3/3] fix(demo): align sdk demo deployment target with iOS demo --- .../oms-sdk-demo.xcodeproj/project.pbxproj | 22 +++----- .../sdk-demo/oms-sdk-demo/ContentView.swift | 56 +++++++++---------- 2 files changed, 33 insertions(+), 45 deletions(-) diff --git a/Examples/sdk-demo/oms-sdk-demo.xcodeproj/project.pbxproj b/Examples/sdk-demo/oms-sdk-demo.xcodeproj/project.pbxproj index 18551a0..286e314 100644 --- a/Examples/sdk-demo/oms-sdk-demo.xcodeproj/project.pbxproj +++ b/Examples/sdk-demo/oms-sdk-demo.xcodeproj/project.pbxproj @@ -294,24 +294,21 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.3; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.polygon.oms-sdk-demo"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; - SDKROOT = auto; + SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 26.4; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -349,24 +346,21 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.3; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.polygon.oms-sdk-demo"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; - SDKROOT = auto; + SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 26.4; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/Examples/sdk-demo/oms-sdk-demo/ContentView.swift b/Examples/sdk-demo/oms-sdk-demo/ContentView.swift index 36e3cc7..3bc0f62 100644 --- a/Examples/sdk-demo/oms-sdk-demo/ContentView.swift +++ b/Examples/sdk-demo/oms-sdk-demo/ContentView.swift @@ -236,7 +236,7 @@ final class AppViewModel: ObservableObject { } func checkSession() async { - let hasSession = !oms.wallet.walletAddress.isEmpty + let hasSession = oms.wallet.walletAddress != nil screen = hasSession ? .wallet : .introduction } @@ -573,7 +573,7 @@ private struct IntroductionFeatureRow: View { VStack(alignment: .leading, spacing: 3) { DesignText(title, variant: .caption) - .fontWeight(.bold) + .font(.custom(DesignTokens.Typography.family, size: 14).weight(.bold)) DesignText(subtitle, variant: .caption) } @@ -847,7 +847,7 @@ private struct WalletSelectionRow: View { VStack(alignment: .leading, spacing: 3) { DesignText(title, variant: .caption) - .fontWeight(.semibold) + .font(.custom(DesignTokens.Typography.family, size: 14).weight(.semibold)) .lineLimit(1) .truncationMode(.middle) @@ -899,7 +899,7 @@ struct WalletWindow: View { } private func refreshBalance() async { - guard !vm.oms.wallet.walletAddress.isEmpty else { return } + guard let walletAddress = vm.oms.wallet.walletAddress else { return } isFetchingBalance = true clearBalance() defer { isFetchingBalance = false } @@ -908,7 +908,7 @@ struct WalletWindow: View { let balances = try await vm.oms.indexer.getTokenBalances( network: selectedNetwork, contractAddress: usdcContractAddress, - walletAddress: vm.oms.wallet.walletAddress, + walletAddress: walletAddress, includeMetadata: false ) if let raw = balances.balances.first?.balance { @@ -921,7 +921,7 @@ struct WalletWindow: View { let nativeTokenBalance = try await vm.oms.indexer.getNativeTokenBalance( network: selectedNetwork, - walletAddress: vm.oms.wallet.walletAddress + walletAddress: walletAddress ) if let raw = nativeTokenBalance?.balance { nativeBalance = formatNativeTokenBalance(raw) @@ -970,7 +970,7 @@ struct WalletWindow: View { } private var walletTab: some View { - NavigationStack { + NavigationView { ScrollView { VStack(spacing: 24) { walletHeader @@ -987,7 +987,7 @@ struct WalletWindow: View { .task { await refreshBalance() } - .onChange(of: selectedNetwork) { + .onChange(of: selectedNetwork) { _ in Task { await refreshBalance() } } } @@ -1015,7 +1015,7 @@ struct WalletWindow: View { private var walletAddressBar: some View { VStack(spacing: 8) { HStack(spacing: 6) { - Text(collapsedAddress(vm.oms.wallet.walletAddress)) + Text(collapsedAddress(vm.oms.wallet.walletAddress ?? "")) .font(.system(size: 22, weight: .semibold, design: .monospaced)) .foregroundStyle(DesignTokens.Color.primaryText) .lineLimit(1) @@ -1023,7 +1023,8 @@ struct WalletWindow: View { .textSelection(.enabled) Button { - Clipboard.copy(vm.oms.wallet.walletAddress) + guard let walletAddress = vm.oms.wallet.walletAddress else { return } + Clipboard.copy(walletAddress) didCopy = true Task { try? await Task.sleep(nanoseconds: 1_500_000_000) @@ -1035,7 +1036,7 @@ struct WalletWindow: View { .foregroundStyle(didCopy ? DesignTokens.Color.success : DesignTokens.Color.info) } .buttonStyle(.plain) - .disabled(vm.oms.wallet.walletAddress.isEmpty) + .disabled(vm.oms.wallet.walletAddress == nil) .help(didCopy ? "Copied" : "Copy address") } @@ -1341,8 +1342,8 @@ struct SendTransactionWindow: View { .environmentObject(vm) } .onAppear { - if toText.isEmpty { - toText = vm.oms.wallet.walletAddress + if toText.isEmpty, let walletAddress = vm.oms.wallet.walletAddress { + toText = walletAddress } } } @@ -1457,20 +1458,12 @@ struct CallContractWindow: View { } ForEach($args) { $arg in - ViewThatFits(in: .horizontal) { + VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { abiTypeField(arg: $arg) - abiValueField(arg: $arg) removeAbiArgButton(arg: arg) } - - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - abiTypeField(arg: $arg) - removeAbiArgButton(arg: arg) - } - abiValueField(arg: $arg) - } + abiValueField(arg: $arg) } } } @@ -1830,7 +1823,7 @@ private struct NavigationScreenContainer: View { } var body: some View { - NavigationStack { + NavigationView { VStack(spacing: 0) { content } @@ -1873,7 +1866,7 @@ private struct ModalContainer: View { } var body: some View { - NavigationStack { + NavigationView { ScrollView { VStack(alignment: .leading, spacing: 20) { HStack(alignment: .center, spacing: 12) { @@ -1935,7 +1928,9 @@ private struct FieldGroup: View { var body: some View { VStack(alignment: .leading, spacing: 8) { DesignText(title, variant: titleStyle == .secondary ? .caption : .body) - .fontWeight(.semibold) + .font(titleStyle == .secondary + ? Font.custom(DesignTokens.Typography.family, size: 14).weight(.semibold) + : Font.custom(DesignTokens.Typography.family, size: 16).weight(.semibold)) content } @@ -2053,7 +2048,7 @@ private struct ResultPanel: View { var body: some View { Panel { DesignText(title, variant: .body) - .fontWeight(.semibold) + .font(.custom(DesignTokens.Typography.family, size: 16).weight(.semibold)) CopyableResult(text: text) } @@ -2066,7 +2061,7 @@ private struct TransactionResultPanel: View { var body: some View { Panel { DesignText("Transaction result", variant: .body) - .fontWeight(.semibold) + .font(.custom(DesignTokens.Typography.family, size: 16).weight(.semibold)) ResultRow(label: "Status", value: result.status.wireValue) ResultRow(label: "Transaction ID", value: result.txnId) @@ -2085,7 +2080,7 @@ private struct ResultRow: View { var body: some View { VStack(alignment: .leading, spacing: 6) { DesignText(label, variant: .caption) - .fontWeight(.semibold) + .font(.custom(DesignTokens.Typography.family, size: 14).weight(.semibold)) CopyableResult(text: value) } @@ -2100,8 +2095,7 @@ struct CopyableResult: View { var body: some View { HStack(spacing: 8) { Text(text) - .font(.footnote) - .monospaced() + .font(.system(.footnote, design: .monospaced)) .foregroundStyle(DesignTokens.Color.secondaryText) .lineLimit(1) .truncationMode(.middle)