From ccce6556b58257814039887843fbda7881ff32ba Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 30 Jun 2026 15:09:08 +0300 Subject: [PATCH 1/7] feat: align public error contracts --- Sources/Swift SDK/Clients/IndexerClient.swift | 128 ++++++++++++--- Sources/Swift SDK/Clients/WalletAuth.swift | 7 +- Sources/Swift SDK/Clients/WalletClient.swift | 4 +- .../Swift SDK/Clients/WalletOperations.swift | 60 ++++--- .../Swift SDK/Clients/WalletSessions.swift | 22 +-- .../Models/Auth/PendingWalletSelection.swift | 12 +- Sources/Swift SDK/Models/OmsSdkError.swift | 150 +++++++++++++++--- 7 files changed, 305 insertions(+), 78 deletions(-) diff --git a/Sources/Swift SDK/Clients/IndexerClient.swift b/Sources/Swift SDK/Clients/IndexerClient.swift index 49bb449..956e42c 100644 --- a/Sources/Swift SDK/Clients/IndexerClient.swift +++ b/Sources/Swift SDK/Clients/IndexerClient.swift @@ -138,18 +138,49 @@ public final class IndexerClient { let bodyData = try encoder.encode(request) let bodyString = String(data: bodyData, encoding: .utf8) ?? "{}" - let response = try await client.postJson( - baseUrl: environment.indexerGatewayUrl, - path: path, - body: bodyString, - headers: defaultHeaders() - ) + let response: HttpResponse + do { + response = try await client.postJson( + baseUrl: environment.indexerGatewayUrl, + path: path, + body: bodyString, + headers: defaultHeaders() + ) + } catch let error as CancellationError { + throw error + } catch { + let upstreamError = indexerTransportUpstreamError(error) + throw OmsSdkError( + code: .requestFailed, + message: upstreamError.message ?? error.localizedDescription, + operation: operation, + retryable: true, + upstreamError: upstreamError, + underlyingError: error + ) + } try validateSuccessResponse(response, operation: operation) - return ( - statusCode: response.statusCode, - payload: try decoder.decode(responseType, from: response.body) - ) + do { + return ( + statusCode: response.statusCode, + payload: try decoder.decode(responseType, from: response.body) + ) + } catch { + let message = "Invalid JSON response from \(operation.rawValue)" + throw OmsSdkError( + code: .invalidResponse, + message: message, + operation: operation, + status: response.statusCode, + upstreamError: OmsUpstreamError( + service: .indexer, + message: message, + status: response.statusCode + ), + underlyingError: error + ) + } } private func chainScope( @@ -186,18 +217,65 @@ public final class IndexerClient { operation: OmsSdkOperation ) throws { guard (200...299).contains(response.statusCode) else { + let fallbackMessage = "\(operation.rawValue) failed with HTTP \(response.statusCode)" + let upstreamError = indexerResponseUpstreamError( + from: response.body, + status: response.statusCode, + fallbackMessage: fallbackMessage + ) throw OmsSdkError( code: .httpError, - message: errorMessage(from: response.body) - ?? "Indexer request failed with HTTP status \(response.statusCode).", + message: upstreamError.message ?? fallbackMessage, operation: operation, status: response.statusCode, - retryable: response.statusCode >= 500 + retryable: response.statusCode >= 500, + upstreamError: upstreamError ) } } } +private func indexerTransportUpstreamError(_ error: any Error) -> OmsUpstreamError { + if let httpError = error as? HttpError, + case .transport(let underlyingError) = httpError { + return OmsUpstreamError( + service: .indexer, + name: String(describing: type(of: underlyingError)), + message: underlyingError.localizedDescription + ) + } + + return OmsUpstreamError( + service: .indexer, + name: String(describing: type(of: error)), + message: error.localizedDescription + ) +} + +private func indexerResponseUpstreamError( + from body: Data, + status: Int, + fallbackMessage: String +) -> OmsUpstreamError { + guard + let payload = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + else { + return OmsUpstreamError( + service: .indexer, + message: fallbackMessage, + status: status + ) + } + + return OmsUpstreamError( + service: .indexer, + name: stringField(payload, "name") ?? stringField(payload, "error"), + code: stringOrNumberField(payload, "code"), + message: stringField(payload, "message") ?? stringField(payload, "msg") ?? fallbackMessage, + status: status + ) +} + private struct TokenBalancesFilter: Encodable { let accountAddresses: [String] let contractStatus: ContractVerificationStatus? @@ -274,18 +352,20 @@ private func nonEmpty(_ values: [T]?) -> [T]? { return values } -private func errorMessage(from body: Data) -> String? { - guard - let payload = try? JSONSerialization.jsonObject(with: body) as? [String: Any] - else { - return nil - } - return stringField(payload, "message") - ?? stringField(payload, "cause") - ?? stringField(payload, "msg") -} - private func stringField(_ payload: [String: Any], _ key: String) -> String? { let value = payload[key] return value as? String } + +private func stringOrNumberField(_ payload: [String: Any], _ key: String) -> String? { + switch payload[key] { + case let value as String: + return value + case let value as Int: + return String(value) + case let value as NSNumber: + return value.stringValue + default: + return nil + } +} diff --git a/Sources/Swift SDK/Clients/WalletAuth.swift b/Sources/Swift SDK/Clients/WalletAuth.swift index 9ce2d56..b9efcbb 100644 --- a/Sources/Swift SDK/Clients/WalletAuth.swift +++ b/Sources/Swift SDK/Clients/WalletAuth.swift @@ -327,6 +327,11 @@ extension WalletClient { code: String, sessionLifetimeSeconds: UInt32 ) async throws -> CompleteAuthResponse { + guard !verifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !challenge.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw OmsSdkError.sessionMissing() + } + let answer = RequestUtils.hashEmailAuthAnswer(challenge: challenge, code: code) let params = CompleteAuthRequest( @@ -472,7 +477,7 @@ extension WalletClient { ) guard !isSessionExpired(selectionSessionState) else { expireSession(selectionSessionState) - throw OmsSdkError.sessionMissing() + throw OmsSdkError.sessionExpired() } try requireActiveCredential() let signerCredentialId = try credentialSession.signer.credentialId() diff --git a/Sources/Swift SDK/Clients/WalletClient.swift b/Sources/Swift SDK/Clients/WalletClient.swift index e0a10a6..2819fd9 100644 --- a/Sources/Swift SDK/Clients/WalletClient.swift +++ b/Sources/Swift SDK/Clients/WalletClient.swift @@ -248,7 +248,7 @@ public class WalletClient: @unchecked Sendable { func requireWalletSelectionOrActiveSession() throws { if let notification = expireCurrentSessionIfNeeded() { deliverSessionExpiredNotification(notification) - throw OmsSdkError.sessionMissing() + throw OmsSdkError.sessionExpired() } let hasActiveSession = withSessionLock { () -> Bool in let hasWallet = !_walletId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -263,7 +263,7 @@ public class WalletClient: @unchecked Sendable { func requireActiveWalletId() throws -> String { if let notification = expireCurrentSessionIfNeeded() { deliverSessionExpiredNotification(notification) - throw OmsSdkError.sessionMissing() + throw OmsSdkError.sessionExpired() } let walletId = withSessionLock { _walletId.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/Swift SDK/Clients/WalletOperations.swift b/Sources/Swift SDK/Clients/WalletOperations.swift index 8dac04d..4d0d8a3 100644 --- a/Sources/Swift SDK/Clients/WalletOperations.swift +++ b/Sources/Swift SDK/Clients/WalletOperations.swift @@ -202,22 +202,11 @@ extension WalletClient { /// - Returns: The current transaction status and transaction hash when available. public func getTransactionStatus(txnId: String) async throws -> TransactionStatusResponse { try await runOmsOperation(.walletGetTransactionStatus) { - do { - return try await signedClient.transactionStatus( - TransactionStatusRequest(txnId: txnId) - ) - } catch let error as CancellationError { - throw error - } catch { - throw OmsSdkError( - code: .transactionStatusLookupFailed, - message: error.localizedDescription, - operation: .walletGetTransactionStatus, - txnId: txnId, - retryable: true, - underlyingError: error - ) - } + _ = try requireActiveWalletId() + try requireActiveCredential() + return try await signedClient.transactionStatus( + TransactionStatusRequest(txnId: txnId) + ) } } @@ -241,7 +230,24 @@ extension WalletClient { feeOption: feeOptionSelection ) - let executeResponse = try await signedClient.execute(executeRequest) + let executeResponse: ExecuteResponse + do { + executeResponse = try await signedClient.execute(executeRequest) + } catch let error as CancellationError { + throw error + } catch { + let sdkError = toOmsSdkError(error, operation: .walletExecute) + throw OmsSdkError( + code: .transactionExecutionUnconfirmed, + message: "Transaction execution failed before status could be confirmed", + operation: .walletExecute, + status: sdkError.status, + txnId: prepareResponse.txnId, + retryable: false, + upstreamError: sdkError.upstreamError, + underlyingError: sdkError + ) + } if !waitForStatus { return SendTransactionResponse( txnId: prepareResponse.txnId, @@ -386,7 +392,25 @@ extension WalletClient { var completedPolls = 0 while true { - lastStatus = try await getTransactionStatus(txnId: txnId) + do { + lastStatus = try await signedClient.transactionStatus( + TransactionStatusRequest(txnId: txnId) + ) + } catch let error as CancellationError { + throw error + } catch { + let sdkError = toOmsSdkError(error, operation: .walletTransactionStatus) + throw OmsSdkError( + code: .transactionStatusLookupFailed, + message: "Transaction was submitted, but status polling failed", + operation: .walletTransactionStatus, + status: sdkError.status, + txnId: txnId, + retryable: true, + upstreamError: sdkError.upstreamError, + underlyingError: sdkError + ) + } completedPolls += 1 if lastStatus.status == .executed diff --git a/Sources/Swift SDK/Clients/WalletSessions.swift b/Sources/Swift SDK/Clients/WalletSessions.swift index bfb22f3..aec8b5a 100644 --- a/Sources/Swift SDK/Clients/WalletSessions.swift +++ b/Sources/Swift SDK/Clients/WalletSessions.swift @@ -349,17 +349,19 @@ public struct ListAccessPages: AsyncSequence { } public mutating func next() async throws -> ListAccessResponse? { - if hasStarted && cursor == nil { - return nil - } + try await runOmsOperation(.walletListAccessPages) { + if hasStarted && cursor == nil { + return nil + } - let response = try await client.listAccessPage( - pageSize: pageSize, - cursor: cursor - ) - hasStarted = true - cursor = nonEmptyCursor(response.page?.cursor) - return response + let response = try await client.listAccessPage( + pageSize: pageSize, + cursor: cursor + ) + hasStarted = true + cursor = nonEmptyCursor(response.page?.cursor) + return response + } } private func nonEmptyCursor(_ cursor: String?) -> String? { diff --git a/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift b/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift index 014fec8..629919a 100644 --- a/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift +++ b/Sources/Swift SDK/Models/Auth/PendingWalletSelection.swift @@ -25,14 +25,18 @@ public final class PendingWalletSelection: @unchecked Sendable { @discardableResult public func selectWallet(walletId: String) async throws -> WalletActivationResult { - guard wallets.contains(where: { $0.id == walletId }) else { - throw OmsSdkError.walletSelectionUnavailable() + try await runOmsOperation(.pendingWalletSelectionSelectWallet) { + guard wallets.contains(where: { $0.id == walletId }) else { + throw OmsSdkError.walletSelectionUnavailable() + } + return try await selectWalletAction(walletId) } - return try await selectWalletAction(walletId) } @discardableResult public func createAndSelectWallet(reference: String? = nil) async throws -> WalletActivationResult { - try await createAndSelectWalletAction(reference) + try await runOmsOperation(.pendingWalletSelectionCreateAndSelectWallet) { + try await createAndSelectWalletAction(reference) + } } } diff --git a/Sources/Swift SDK/Models/OmsSdkError.swift b/Sources/Swift SDK/Models/OmsSdkError.swift index 7f4f38d..183cda8 100644 --- a/Sources/Swift SDK/Models/OmsSdkError.swift +++ b/Sources/Swift SDK/Models/OmsSdkError.swift @@ -10,11 +10,15 @@ public enum OmsSdkErrorCode: String, Sendable { case walletSelectionStale = "OMS_WALLET_SELECTION_STALE" case walletSelectionUnavailable = "OMS_WALLET_SELECTION_UNAVAILABLE" case walletSelectionInFlight = "OMS_WALLET_SELECTION_IN_FLIGHT" + case transactionExecutionUnconfirmed = "OMS_TRANSACTION_EXECUTION_UNCONFIRMED" case transactionStatusLookupFailed = "OMS_TRANSACTION_STATUS_LOOKUP_FAILED" case validationError = "OMS_VALIDATION_ERROR" } public enum OmsSdkOperation: String, Sendable { + case pendingWalletSelection = "wallet.pendingWalletSelection" + case pendingWalletSelectionSelectWallet = "wallet.pendingWalletSelection.selectWallet" + case pendingWalletSelectionCreateAndSelectWallet = "wallet.pendingWalletSelection.createAndSelectWallet" case walletStartEmailAuth = "wallet.startEmailAuth" case walletCompleteEmailAuth = "wallet.completeEmailAuth" case walletSignInWithOidcIdToken = "wallet.signInWithOidcIdToken" @@ -26,6 +30,7 @@ public enum OmsSdkOperation: String, Sendable { case walletSignOut = "wallet.signOut" case walletListAccess = "wallet.listAccess" case walletListAccessPage = "wallet.listAccessPage" + case walletListAccessPages = "wallet.listAccessPages" case walletGetIdToken = "wallet.getIdToken" case walletRevokeAccess = "wallet.revokeAccess" case walletSignMessage = "wallet.signMessage" @@ -34,17 +39,47 @@ public enum OmsSdkOperation: String, Sendable { case walletIsValidTypedDataSignature = "wallet.isValidTypedDataSignature" case walletSendTransaction = "wallet.sendTransaction" case walletCallContract = "wallet.callContract" + case walletExecute = "wallet.execute" case walletGetTransactionStatus = "wallet.getTransactionStatus" + case walletTransactionStatus = "wallet.transactionStatus" case indexerGetBalances = "indexer.getBalances" case indexerGetTransactionHistory = "indexer.getTransactionHistory" } +public enum OmsUpstreamService: String, Sendable { + case waas = "Waas" + case indexer = "Indexer" +} + +public struct OmsUpstreamError: Equatable, Sendable { + public let service: OmsUpstreamService + public let name: String? + public let code: String? + public let message: String? + public let status: Int? + + public init( + service: OmsUpstreamService, + name: String? = nil, + code: String? = nil, + message: String? = nil, + status: Int? = nil + ) { + self.service = service + self.name = name + self.code = code + self.message = message + self.status = status + } +} + public struct OmsSdkError: Error, LocalizedError, @unchecked Sendable { public let code: OmsSdkErrorCode public let operation: OmsSdkOperation? public let status: Int? public let txnId: String? - public let retryable: Bool + public let retryable: Bool? + public let upstreamError: OmsUpstreamError? public let underlyingError: (any Error)? private let message: String @@ -54,7 +89,8 @@ public struct OmsSdkError: Error, LocalizedError, @unchecked Sendable { operation: OmsSdkOperation? = nil, status: Int? = nil, txnId: String? = nil, - retryable: Bool = false, + retryable: Bool? = nil, + upstreamError: OmsUpstreamError? = nil, underlyingError: (any Error)? = nil ) { self.code = code @@ -63,6 +99,7 @@ public struct OmsSdkError: Error, LocalizedError, @unchecked Sendable { self.status = status self.txnId = txnId self.retryable = retryable + self.upstreamError = upstreamError self.underlyingError = underlyingError } @@ -141,7 +178,7 @@ func runOmsOperation( func toOmsSdkError(_ error: any Error, operation: OmsSdkOperation) -> OmsSdkError { if let omsError = error as? OmsSdkError { - if omsError.operation == operation { + if omsError.operation == operation || omsError.isNestedTransactionBoundary { return omsError } return OmsSdkError( @@ -151,6 +188,7 @@ func toOmsSdkError(_ error: any Error, operation: OmsSdkOperation) -> OmsSdkErro status: omsError.status, txnId: omsError.txnId, retryable: omsError.retryable, + upstreamError: omsError.upstreamError, underlyingError: omsError ) } @@ -165,6 +203,7 @@ func toOmsSdkError(_ error: any Error, operation: OmsSdkOperation) -> OmsSdkErro message: transportError.message, operation: operation, retryable: true, + upstreamError: transportError.toWaasUpstreamError(), underlyingError: transportError ) } @@ -196,47 +235,101 @@ func toOmsSdkError(_ error: any Error, operation: OmsSdkOperation) -> OmsSdkErro private extension WebRPCError { func toOmsSdkError(operation: OmsSdkOperation) -> OmsSdkError { + let normalizedStatus = normalizedStatus + let upstreamError = toWaasUpstreamError(status: normalizedStatus) + let normalizedMessage = normalizedMessage + if kind == .commitmentConsumed { return OmsSdkError( code: .authCommitmentConsumed, - message: message, + message: normalizedMessage, operation: operation, - status: status, + status: normalizedStatus, retryable: false, + upstreamError: upstreamError, underlyingError: self ) } - if kind == .webrpcBadResponse || kind == .unknown && code == WebRPCErrorKind.unknown.code { + if isHttpWebRPCError(status: normalizedStatus) { return OmsSdkError( - code: .invalidResponse, - message: message, + code: .httpError, + message: normalizedMessage, operation: operation, - status: status, + status: normalizedStatus, + retryable: normalizedStatus.map { $0 >= 500 } ?? false, + upstreamError: upstreamError, underlyingError: self ) } - if isHttpStatus(status) { + if kind == .webrpcBadResponse || kind == .unknown && code == WebRPCErrorKind.unknown.code { return OmsSdkError( - code: .httpError, - message: message, + code: .invalidResponse, + message: normalizedMessage, operation: operation, - status: status, - retryable: status >= 500, + status: normalizedStatus, + upstreamError: upstreamError, underlyingError: self ) } return OmsSdkError( code: .requestFailed, - message: message, + message: normalizedMessage, operation: operation, - status: status, - retryable: true, + status: normalizedStatus, + retryable: normalizedStatus.map { $0 >= 500 } ?? true, + upstreamError: upstreamError, underlyingError: self ) } + + private var normalizedStatus: Int? { + if error == "WebrpcRequestFailed", + code == WebRPCErrorKind.webrpcRequestFailed.code, + status == 400 { + return nil + } + return status + } + + private var normalizedCode: String { + if error == "WebrpcBadResponse", code == WebRPCErrorKind.unknown.code { + return String(WebRPCErrorKind.webrpcBadResponse.code) + } + return String(code) + } + + private var normalizedMessage: String { + if error == "WebrpcBadResponse", code == WebRPCErrorKind.unknown.code { + return "bad response" + } + return message + } + + private func toWaasUpstreamError(status: Int?) -> OmsUpstreamError { + OmsUpstreamError( + service: .waas, + name: error, + code: normalizedCode, + message: normalizedMessage, + status: status + ) + } + + private func isHttpWebRPCError(status: Int?) -> Bool { + guard let status, status >= 400 && status <= 599 else { + return false + } + + switch kind { + case .webrpcBadRoute, .webrpcBadMethod, .webrpcBadRequest, .webrpcBadResponse: + return true + default: + return error == "WebrpcBadResponse" + } + } } private extension TransactionError { @@ -285,6 +378,11 @@ private extension HttpError { message: error.localizedDescription, operation: operation, retryable: true, + upstreamError: OmsUpstreamError( + service: .indexer, + name: String(describing: type(of: error)), + message: error.localizedDescription + ), underlyingError: self ) case .invalidUrl, .encodingFailed: @@ -298,6 +396,20 @@ private extension HttpError { } } -private func isHttpStatus(_ status: Int) -> Bool { - status >= 100 && status <= 599 +private extension WebRPCTransportError { + func toWaasUpstreamError() -> OmsUpstreamError { + OmsUpstreamError( + service: .waas, + name: "WebrpcRequestFailed", + code: String(WebRPCErrorKind.webrpcRequestFailed.code), + message: message, + status: nil + ) + } +} + +private extension OmsSdkError { + var isNestedTransactionBoundary: Bool { + code == .transactionExecutionUnconfirmed || code == .transactionStatusLookupFailed + } } From b90b4f0961d1f1068b8990d2e9b5db56f5baa7d7 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 30 Jun 2026 15:09:15 +0300 Subject: [PATCH 2/7] test: add public error contract coverage --- Tests/Swift SDKTests/IndexerTests.swift | 8 +- Tests/Swift SDKTests/MockWalletTests.swift | 66 +- .../PublicErrorContractsTests.swift | 789 ++++++++++++++++++ 3 files changed, 836 insertions(+), 27 deletions(-) create mode 100644 Tests/Swift SDKTests/PublicErrorContractsTests.swift diff --git a/Tests/Swift SDKTests/IndexerTests.swift b/Tests/Swift SDKTests/IndexerTests.swift index ad77ef3..22b4442 100644 --- a/Tests/Swift SDKTests/IndexerTests.swift +++ b/Tests/Swift SDKTests/IndexerTests.swift @@ -372,14 +372,14 @@ import Testing #expect(error.operation == .indexerGetTransactionHistory) #expect(error.status == 404) #expect(error.retryable == false) - #expect(error.localizedDescription == "not found") + #expect(error.localizedDescription == "indexer.getTransactionHistory failed with HTTP 404") } catch { #expect(Bool(false), "Expected OmsSdkError") } } @available(macOS 12.0, iOS 15.0, *) -private func makeRecordingIndexerClient(recorder: IndexerRequestRecorder) -> IndexerClient { +func makeRecordingIndexerClient(recorder: IndexerRequestRecorder) -> IndexerClient { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [RecordingURLProtocol.self] configuration.timeoutIntervalForRequest = 1 @@ -400,7 +400,7 @@ private func makeRecordingIndexerClient(recorder: IndexerRequestRecorder) -> Ind ) } -private final class IndexerRequestRecorder: @unchecked Sendable { +final class IndexerRequestRecorder: @unchecked Sendable { private let lock = NSLock() let statusCode: Int let responseBody: Data @@ -438,7 +438,7 @@ private final class IndexerRequestRecorder: @unchecked Sendable { } } -private final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { +final class RecordingURLProtocol: URLProtocol, @unchecked Sendable { private static let lock = NSLock() nonisolated(unsafe) private static var recordersByHost: [String: IndexerRequestRecorder] = [:] diff --git a/Tests/Swift SDKTests/MockWalletTests.swift b/Tests/Swift SDKTests/MockWalletTests.swift index e1c4bac..5f7421d 100644 --- a/Tests/Swift SDKTests/MockWalletTests.swift +++ b/Tests/Swift SDKTests/MockWalletTests.swift @@ -134,7 +134,7 @@ import Testing } now = Date(timeIntervalSince1970: 1_767_225_601) - await expectNoAuthenticatedWalletSession { + await expectNoAuthenticatedWalletSession(expectedCode: .sessionExpired) { try await fixture.client.signMessage(network: .polygon, message: "hello") } @@ -301,7 +301,7 @@ import Testing #expect(Bool(false)) } catch let error as OmsSdkError { #expect(error.code == .walletSelectionUnavailable) - #expect(error.operation == nil) + #expect(error.operation == .pendingWalletSelectionSelectWallet) } catch { #expect(Bool(false)) } @@ -320,7 +320,7 @@ import Testing #expect(Bool(false)) } catch let error as OmsSdkError { #expect(error.code == .walletSelectionStale) - #expect(error.operation == nil) + #expect(error.operation == .pendingWalletSelectionCreateAndSelectWallet) } catch { #expect(Bool(false)) } @@ -408,7 +408,7 @@ import Testing #expect(Bool(false), "Stale pending wallet selection should not create and select a wallet after a newer auth flow") } catch let error as OmsSdkError { #expect(error.code == .walletSelectionStale) - #expect(error.operation == nil) + #expect(error.operation == .pendingWalletSelectionCreateAndSelectWallet) #expect(fixture.transport.requestCount(for: WaasAPI.CreateWallet.urlPath) == 0) #expect(fixture.client.walletId == "") #expect(fixture.client.walletAddress == nil) @@ -501,10 +501,9 @@ import Testing _ = try await fixture.client.completeEmailAuth(code: "123456") #expect(Bool(false)) } catch let error as OmsSdkError { - #expect(error.code == .httpError) + #expect(error.code == .requestFailed) #expect(error.operation == .walletCompleteEmailAuth) #expect(error.status == 500) - #expect((error.underlyingError as? WebRPCError)?.code == WebRPCErrorKind.internalError.code) } catch { #expect(Bool(false)) } @@ -708,10 +707,9 @@ import Testing ) #expect(Bool(false)) } catch let error as OmsSdkError { - #expect(error.code == .httpError) + #expect(error.code == .requestFailed) #expect(error.operation == .walletSignInWithOidcIdToken) #expect(error.status == 500) - #expect((error.underlyingError as? WebRPCError)?.code == WebRPCErrorKind.identityProviderError.code) } catch { #expect(Bool(false)) } @@ -1391,6 +1389,8 @@ import Testing @Test func TestWalletGetTransactionStatusKeepsCancellationError() async throws { let fixture = makeMockWalletClient() + fixture.client.walletId = "wallet-main" + fixture.client.walletAddress = "0x1111111111111111111111111111111111111111" fixture.transport.enqueueCancellation(for: WaasAPI.TransactionStatusMethod.urlPath) do { @@ -2029,20 +2029,21 @@ import Testing #expect(fixture.transport.requestCount(for: WaasAPI.Execute.urlPath) == 0) } -private let testCredential = CredentialInfo( +let testCredential = CredentialInfo( credentialId: "0xcredential", expiresAt: "2099-01-01T00:00:00Z", isCaller: true ) private func expectNoAuthenticatedWalletSession( + expectedCode: OmsSdkErrorCode = .sessionMissing, _ operation: () async throws -> T ) async { do { _ = try await operation() #expect(Bool(false), "Expected no authenticated wallet session error") } catch let error as OmsSdkError { - #expect(error.code == .sessionMissing) + #expect(error.code == expectedCode) #expect(error.operation != nil) } catch { #expect(Bool(false), "Expected OmsSdkError.sessionMissing") @@ -2067,7 +2068,7 @@ private func isTransactionError(_ error: (any Error)?, _ expected: TransactionEr } } -private struct MockWalletClientFixture { +struct MockWalletClientFixture { let client: WalletClient let transport: MockWaasTransport let signer: MockCredentialSigner @@ -2087,7 +2088,7 @@ private struct MockWalletClientFixture { } } -private func makeMockWalletClient( +func makeMockWalletClient( environment: OMSClientEnvironment = OMSClientEnvironment( walletApiUrl: "https://wallet.example.test" ), @@ -2152,7 +2153,7 @@ private func makeMockWalletClient( ) } -private func completeAuthResponse( +func completeAuthResponse( identity: Identity = Identity(type: .email, sub: "user@example.com"), wallets: [Wallet], page: Page? = nil, @@ -2168,7 +2169,7 @@ private func completeAuthResponse( ) } -private func testWallet( +func testWallet( id: String, type: WalletType = .ethereum, address: String @@ -2180,7 +2181,7 @@ private func testWallet( ) } -private func testFeeOptions() -> [FeeOption] { +func testFeeOptions() -> [FeeOption] { [ FeeOption( token: FeeToken( @@ -2213,7 +2214,7 @@ private func testFeeOptions() -> [FeeOption] { ] } -private final class MockIndexerBackend: @unchecked Sendable { +final class MockIndexerBackend: @unchecked Sendable { private struct RecordedBalanceRequest { let contractAddresses: [String] let chainIds: [Int64] @@ -2330,7 +2331,7 @@ private final class MockIndexerBackend: @unchecked Sendable { } } -private final class MockIndexerURLProtocol: URLProtocol, @unchecked Sendable { +final class MockIndexerURLProtocol: URLProtocol, @unchecked Sendable { private static let lock = NSLock() nonisolated(unsafe) private static var backendsByHost: [String: MockIndexerBackend] = [:] @@ -2445,7 +2446,7 @@ private func urlDecode(_ value: String) -> String { .removingPercentEncoding ?? value } -private final class InMemoryKeychain: KeychainManaging { +final class InMemoryKeychain: KeychainManaging { private let lock = NSLock() private var storage: [String: String] = [:] @@ -2475,7 +2476,7 @@ private final class InMemoryKeychain: KeychainManaging { } } -private final class InMemoryOidcRedirectAuthStore: OidcRedirectAuthStore, @unchecked Sendable { +final class InMemoryOidcRedirectAuthStore: OidcRedirectAuthStore, @unchecked Sendable { private let lock = NSLock() private(set) var pending: PendingOidcRedirectAuth? @@ -2498,7 +2499,7 @@ private final class InMemoryOidcRedirectAuthStore: OidcRedirectAuthStore, @unche } } -private final class MockCredentialSigner: CredentialSigner, @unchecked Sendable { +final class MockCredentialSigner: CredentialSigner, @unchecked Sendable { let alg: SigningAlgorithm = .ecdsaP256Sha256 private let lock = NSLock() @@ -2544,19 +2545,19 @@ private final class MockCredentialSigner: CredentialSigner, @unchecked Sendable } } -private struct RecordedWebRPCRequest: Sendable { +struct RecordedWebRPCRequest: Sendable { let path: String let body: Data } -private enum MockWaasResponse: Sendable { +enum MockWaasResponse: Sendable { case success(Data) case httpError(statusCode: Int, body: Data) case failure(WebRPCTransportError) case cancellation } -private final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { +final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { enum MockError: Error { case unexpectedRequest(String) case missingRecordedRequest(String, Int) @@ -2589,6 +2590,25 @@ private final class MockWaasTransport: WebRPCTransport, @unchecked Sendable { responses[path, default: []].append(.httpError(statusCode: statusCode, body: body)) } + func enqueueRawHTTPError( + statusCode: Int, + body: Data, + for path: String + ) { + lock.lock() + defer { lock.unlock() } + responses[path, default: []].append(.httpError(statusCode: statusCode, body: body)) + } + + func enqueueTransportError( + _ error: WebRPCTransportError, + for path: String + ) { + lock.lock() + defer { lock.unlock() } + responses[path, default: []].append(.failure(error)) + } + func enqueueCancellation(for path: String) { lock.lock() defer { lock.unlock() } diff --git a/Tests/Swift SDKTests/PublicErrorContractsTests.swift b/Tests/Swift SDKTests/PublicErrorContractsTests.swift new file mode 100644 index 0000000..75d1740 --- /dev/null +++ b/Tests/Swift SDKTests/PublicErrorContractsTests.swift @@ -0,0 +1,789 @@ +import Foundation +import Testing +@testable import OMS_SDK + +@Test func TestPublicErrorContractsWaasTransportFailuresHaveUpstreamDetails() async throws { + let fixture = makeMockWalletClient() + fixture.transport.enqueueTransportError( + WebRPCTransportError(message: "WebRPC request failed"), + for: WaasAPI.CommitVerifier.urlPath + ) + + await expectPublicError( + try await fixture.client.startEmailAuth(email: "user@example.com"), + equals: error( + code: .requestFailed, + operation: .walletStartEmailAuth, + message: "WebRPC request failed", + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcRequestFailed", + code: "-1", + message: "WebRPC request failed" + ) + ) + ) +} + +@Test func TestPublicErrorContractsWaasDomainErrorsHaveUpstreamDetails() async throws { + let fixture = makeMockWalletClient() + fixture.transport.enqueueRawHTTPError( + statusCode: 400, + body: Data( + """ + {"error":"CommitmentConsumed","code":7008,"msg":"The authentication commitment has already been used","status":400} + """.utf8 + ), + for: WaasAPI.CompleteAuth.urlPath + ) + + await expectPublicError( + try await fixture.client.completeEmailAuth(code: "123456"), + equals: error( + code: .authCommitmentConsumed, + operation: .walletCompleteEmailAuth, + message: "The authentication commitment has already been used", + status: 400, + retryable: false, + upstreamError: upstream( + service: .waas, + name: "CommitmentConsumed", + code: "7008", + message: "The authentication commitment has already been used", + status: 400 + ) + ) + ) +} + +@Test func TestPublicErrorContractsWaasHttpAndBadResponsesHaveUpstreamDetails() async throws { + let httpFixture = makeRestoredWalletClient() + httpFixture.transport.enqueueRawHTTPError( + statusCode: 500, + body: Data( + """ + {"error":"WebrpcBadRequest","code":-4,"msg":"bad request","status":500} + """.utf8 + ), + for: WaasAPI.SignMessage.urlPath + ) + + await expectPublicError( + try await httpFixture.client.signMessage(network: .polygon, message: "hello"), + equals: error( + code: .httpError, + operation: .walletSignMessage, + message: "bad request", + status: 500, + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcBadRequest", + code: "-4", + message: "bad request", + status: 500 + ) + ) + ) + + let nonJsonFixture = makeRestoredWalletClient() + nonJsonFixture.transport.enqueueRawHTTPError( + statusCode: 502, + body: Data("Bad Gateway".utf8), + for: WaasAPI.SignMessage.urlPath + ) + + let failure = await publicError { + try await nonJsonFixture.client.signMessage(network: .polygon, message: "hello") + } + #expect(failure == error( + code: .httpError, + operation: .walletSignMessage, + message: "bad response", + status: 502, + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcBadResponse", + code: "-5", + message: "bad response", + status: 502 + ) + )) + #expect(failure.message?.contains("Bad Gateway") == false) + #expect(failure.upstreamError?.message?.contains("Bad Gateway") == false) +} + +@Test func TestPublicErrorContractsLocalAuthAndSelectionErrorsHaveNoUpstreamDetails() async throws { + let emailFixture = makeMockWalletClient() + emailFixture.client.verifier = "" + emailFixture.client.challenge = "" + + await expectPublicError( + try await emailFixture.client.completeEmailAuth(code: "123456"), + equals: error( + code: .sessionMissing, + operation: .walletCompleteEmailAuth, + message: "No authenticated wallet session." + ) + ) + + let selectionFixture = makeMockWalletClient() + let availableWallet = testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111") + try selectionFixture.transport.enqueue( + completeAuthResponse(wallets: [availableWallet]), + for: WaasAPI.CompleteAuth.urlPath + ) + let result = try await selectionFixture.client.completeEmailAuth( + code: "123456", + walletSelection: .manual + ) + guard case .walletSelection(let pendingSelection) = result else { + Issue.record("Expected pending wallet selection") + return + } + + await expectPublicError( + try await pendingSelection.selectWallet(walletId: "wallet-missing"), + equals: error( + code: .walletSelectionUnavailable, + operation: .pendingWalletSelectionSelectWallet, + message: "Selected wallet is not one of the available options." + ) + ) + + selectionFixture.client.activePendingWalletSelection = nil + await expectPublicError( + try await pendingSelection.createAndSelectWallet(), + equals: error( + code: .walletSelectionStale, + operation: .pendingWalletSelectionCreateAndSelectWallet, + message: "Pending wallet selection is no longer active." + ) + ) +} + +@Test func TestPublicErrorContractsPendingSelectionBackendErrorsAreNormalized() async throws { + let fixture = makeMockWalletClient() + let availableWallet = testWallet(id: "wallet-1", address: "0x1111111111111111111111111111111111111111") + try fixture.transport.enqueue( + completeAuthResponse(wallets: [availableWallet]), + for: WaasAPI.CompleteAuth.urlPath + ) + fixture.transport.enqueueRawHTTPError( + statusCode: 500, + body: Data(#"{"error":"WebrpcBadRequest","code":-4,"msg":"use wallet failed","status":500}"#.utf8), + for: WaasAPI.UseWallet.urlPath + ) + + let result = try await fixture.client.completeEmailAuth( + code: "123456", + walletSelection: .manual + ) + guard case .walletSelection(let pendingSelection) = result else { + Issue.record("Expected pending wallet selection") + return + } + + await expectPublicError( + try await pendingSelection.selectWallet(walletId: availableWallet.id), + equals: error( + code: .httpError, + operation: .pendingWalletSelectionSelectWallet, + message: "use wallet failed", + status: 500, + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcBadRequest", + code: "-4", + message: "use wallet failed", + status: 500 + ) + ) + ) +} + +@Test func TestPublicErrorContractsMissingAndExpiredSessionErrorsHaveNoUpstreamDetails() async throws { + let missingFixture = makeMockWalletClient() + + await expectPublicError( + try await missingFixture.client.signMessage(network: .polygon, message: "hello"), + equals: error( + code: .sessionMissing, + operation: .walletSignMessage, + message: "No authenticated wallet session." + ) + ) + + await expectPublicError( + try await missingFixture.client.getTransactionStatus(txnId: "txn-missing"), + equals: error( + code: .sessionMissing, + operation: .walletGetTransactionStatus, + message: "No authenticated wallet session." + ) + ) + + var iterator = missingFixture.client.listAccessPages().makeAsyncIterator() + await expectPublicError( + try await iterator.next(), + equals: error( + code: .sessionMissing, + operation: .walletListAccessPages, + message: "No authenticated wallet session." + ) + ) + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiredFixture = makeMockWalletClient(currentDate: { now }) + expiredFixture.client.walletId = "wallet-main" + expiredFixture.client.walletAddress = "0xwallet" + expiredFixture.client.sessionExpiresAt = "2025-01-01T00:00:00Z" + + await expectPublicError( + try await expiredFixture.client.signMessage(network: .polygon, message: "hello"), + equals: error( + code: .sessionExpired, + operation: .walletSignMessage, + message: "No active credential." + ) + ) +} + +@Test func TestPublicErrorContractsOidcLocalErrorsHaveNoUpstreamDetails() async throws { + let fixture = makeMockWalletClient(oidcNonceGenerator: { "nonce-123" }) + try fixture.transport.enqueue( + CommitVerifierResponse( + verifier: "oidc-verifier", + loginHint: nil, + challenge: "pkce-challenge" + ), + for: WaasAPI.CommitVerifier.urlPath + ) + + let started = try await fixture.client.startOidcRedirectAuth( + provider: OidcProviderConfig( + issuer: "https://issuer.example.test", + clientId: "client-123", + authorizationUrl: "https://issuer.example.test/authorize" + ), + redirectUri: "omssdkdemo://auth/callback" + ) + + let result = try await fixture.client.handleOidcRedirectCallback( + "omssdkdemo://auth/callback?error=access_denied&error_description=User%20cancelled&state=\(started.state)" + ) + + guard case .failed(let error as OmsSdkError) = result else { + Issue.record("Expected OIDC failure") + return + } + #expect(serialize(error) == PublicErrorContract( + code: .validationError, + operation: .walletHandleOidcRedirectCallback, + message: "User cancelled", + status: nil, + retryable: nil, + txnId: nil, + upstreamError: nil + )) +} + +@Test func TestPublicErrorContractsLocalTransactionValidationErrorsHaveNoUpstreamDetails() async throws { + let fixture = makeRestoredWalletClient() + try fixture.transport.enqueue( + PrepareResponse( + txnId: "txn-no-fees", + status: .quoted, + feeOptions: [], + sponsored: false, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasAPI.PrepareEthereumTransaction.urlPath + ) + + await expectPublicError( + try await fixture.client.sendTransaction( + network: .polygon, + request: SendTransactionRequest(to: "0x1111111111111111111111111111111111111111", value: "0") + ), + equals: error( + code: .validationError, + operation: .walletSendTransaction, + message: "No fee options are available for this transaction." + ) + ) +} + +@Test func TestPublicErrorContractsBackendFailuresOnWalletReadMethodsHaveUpstreamDetails() async throws { + let signatureFixture = makeRestoredWalletClient() + signatureFixture.transport.enqueueRawHTTPError( + statusCode: 500, + body: Data(#"{"error":"WebrpcBadRequest","code":-4,"msg":"signature backend failed","status":500}"#.utf8), + for: WaasPublicAPI.IsValidMessageSignature.urlPath + ) + + await expectPublicError( + try await signatureFixture.client.isValidMessageSignature( + network: .polygon, + walletAddress: "0xwallet", + message: "hello", + signature: "0xsig" + ), + equals: error( + code: .httpError, + operation: .walletIsValidMessageSignature, + message: "signature backend failed", + status: 500, + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcBadRequest", + code: "-4", + message: "signature backend failed", + status: 500 + ) + ) + ) + + let accessFixture = makeRestoredWalletClient() + accessFixture.transport.enqueueRawHTTPError( + statusCode: 500, + body: Data(#"{"error":"WebrpcBadRequest","code":-4,"msg":"access backend failed","status":500}"#.utf8), + for: WaasAPI.ListAccess.urlPath + ) + + await expectPublicError( + try await accessFixture.client.listAccessPage(), + equals: error( + code: .httpError, + operation: .walletListAccessPage, + message: "access backend failed", + status: 500, + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcBadRequest", + code: "-4", + message: "access backend failed", + status: 500 + ) + ) + ) +} + +@Test func TestPublicErrorContractsDirectTransactionStatusBackendFailuresUsePublicOperation() async throws { + let fixture = makeRestoredWalletClient() + fixture.transport.enqueueRawHTTPError( + statusCode: 404, + body: Data(#"{"error":"TransactionNotFound","code":7308,"msg":"Transaction not found","status":404}"#.utf8), + for: WaasAPI.TransactionStatusMethod.urlPath + ) + + await expectPublicError( + try await fixture.client.getTransactionStatus(txnId: "txn-direct"), + equals: error( + code: .requestFailed, + operation: .walletGetTransactionStatus, + message: "Transaction not found", + status: 404, + retryable: false, + upstreamError: upstream( + service: .waas, + name: "TransactionNotFound", + code: "7308", + message: "Transaction not found", + status: 404 + ) + ) + ) +} + +@Test func TestPublicErrorContractsTransactionExecuteFailuresAreUnconfirmedWrites() async throws { + let fixture = makeRestoredWalletClient() + try fixture.transport.enqueue( + PrepareResponse( + txnId: "txn-execute", + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasAPI.PrepareEthereumTransaction.urlPath + ) + fixture.transport.enqueueRawHTTPError( + statusCode: 502, + body: Data("Bad Gateway".utf8), + for: WaasAPI.Execute.urlPath + ) + + await expectPublicError( + try await fixture.client.sendTransaction( + network: .polygon, + request: SendTransactionRequest(to: "0x1111111111111111111111111111111111111111", value: "0") + ), + equals: error( + code: .transactionExecutionUnconfirmed, + operation: .walletExecute, + message: "Transaction execution failed before status could be confirmed", + status: 502, + retryable: false, + txnId: "txn-execute", + upstreamError: upstream( + service: .waas, + name: "WebrpcBadResponse", + code: "-5", + message: "bad response", + status: 502 + ) + ) + ) +} + +@Test func TestPublicErrorContractsTransactionStatusPollingFailuresPreserveTxnAndUpstreamDetails() async throws { + let backendFixture = makeRestoredWalletClient() + try backendFixture.transport.enqueue( + PrepareResponse( + txnId: "txn-status", + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasAPI.PrepareEthereumTransaction.urlPath + ) + try backendFixture.transport.enqueue( + ExecuteResponse(status: .pending), + for: WaasAPI.Execute.urlPath + ) + backendFixture.transport.enqueueRawHTTPError( + statusCode: 404, + body: Data(#"{"error":"TransactionNotFound","code":7308,"msg":"Transaction not found","status":404}"#.utf8), + for: WaasAPI.TransactionStatusMethod.urlPath + ) + + await expectPublicError( + try await backendFixture.client.sendTransaction( + network: .polygon, + request: SendTransactionRequest(to: "0x1111111111111111111111111111111111111111", value: "0") + ), + equals: error( + code: .transactionStatusLookupFailed, + operation: .walletTransactionStatus, + message: "Transaction was submitted, but status polling failed", + status: 404, + retryable: true, + txnId: "txn-status", + upstreamError: upstream( + service: .waas, + name: "TransactionNotFound", + code: "7308", + message: "Transaction not found", + status: 404 + ) + ) + ) + + let transportFixture = makeRestoredWalletClient() + try transportFixture.transport.enqueue( + PrepareResponse( + txnId: "txn-transport", + status: .quoted, + feeOptions: [], + sponsored: true, + expiresAt: "2026-04-27T00:00:00Z" + ), + for: WaasAPI.PrepareEthereumTransaction.urlPath + ) + try transportFixture.transport.enqueue( + ExecuteResponse(status: .pending), + for: WaasAPI.Execute.urlPath + ) + transportFixture.transport.enqueueTransportError( + WebRPCTransportError(message: "WebRPC request failed"), + for: WaasAPI.TransactionStatusMethod.urlPath + ) + + await expectPublicError( + try await transportFixture.client.sendTransaction( + network: .polygon, + request: SendTransactionRequest(to: "0x1111111111111111111111111111111111111111", value: "0") + ), + equals: error( + code: .transactionStatusLookupFailed, + operation: .walletTransactionStatus, + message: "Transaction was submitted, but status polling failed", + retryable: true, + txnId: "txn-transport", + upstreamError: upstream( + service: .waas, + name: "WebrpcRequestFailed", + code: "-1", + message: "WebRPC request failed" + ) + ) + ) +} + +@Test func TestPublicErrorContractsIndexerFailuresHaveUpstreamDetails() async throws { + let backendClient = makeRecordingIndexerClient( + recorder: IndexerRequestRecorder( + statusCode: 500, + responseBody: Data(#"{"error":"IndexerDown","code":123,"message":"down"}"#.utf8) + ) + ) + + await expectPublicError( + try await backendClient.getBalances( + GetBalancesParams(walletAddress: "0xwallet", networks: [.polygon]) + ), + equals: error( + code: .httpError, + operation: .indexerGetBalances, + message: "down", + status: 500, + retryable: true, + upstreamError: upstream( + service: .indexer, + name: "IndexerDown", + code: "123", + message: "down", + status: 500 + ) + ) + ) + + let nonJsonClient = makeRecordingIndexerClient( + recorder: IndexerRequestRecorder( + statusCode: 502, + responseBody: Data("Bad Gateway".utf8) + ) + ) + let nonJsonFailure = await publicError { + try await nonJsonClient.getBalances( + GetBalancesParams(walletAddress: "0xwallet", networks: [.polygon]) + ) + } + #expect(nonJsonFailure == error( + code: .httpError, + operation: .indexerGetBalances, + message: "indexer.getBalances failed with HTTP 502", + status: 502, + retryable: true, + upstreamError: upstream( + service: .indexer, + message: "indexer.getBalances failed with HTTP 502", + status: 502 + ) + )) + #expect(nonJsonFailure.message?.contains("Bad Gateway") == false) + #expect(nonJsonFailure.upstreamError?.message?.contains("Bad Gateway") == false) + + let transportClient = makeRecordingIndexerClient( + recorder: IndexerRequestRecorder(transportError: URLError(.cannotConnectToHost)) + ) + await expectPublicError( + try await transportClient.getBalances( + GetBalancesParams(walletAddress: "0xwallet", networks: [.polygon]) + ), + equals: error( + code: .requestFailed, + operation: .indexerGetBalances, + message: URLError(.cannotConnectToHost).localizedDescription, + retryable: true, + upstreamError: upstream( + service: .indexer, + name: "NSURLError", + message: URLError(.cannotConnectToHost).localizedDescription + ) + ) + ) + + let malformedClient = makeRecordingIndexerClient( + recorder: IndexerRequestRecorder( + statusCode: 200, + responseBody: Data("not json".utf8) + ) + ) + await expectPublicError( + try await malformedClient.getTransactionHistory( + GetTransactionHistoryParams(walletAddress: "0xwallet", networks: [.polygon]) + ), + equals: error( + code: .invalidResponse, + operation: .indexerGetTransactionHistory, + message: "Invalid JSON response from indexer.getTransactionHistory", + status: 200, + upstreamError: upstream( + service: .indexer, + message: "Invalid JSON response from indexer.getTransactionHistory", + status: 200 + ) + ) + ) +} + +@Test func TestPublicErrorContractsConstructedErrorFieldsAreStable() { + let upstreamError = OmsUpstreamError( + service: .waas, + name: "WebrpcRequestFailed", + code: "-1", + message: "request failed", + status: nil + ) + let sdkError = OmsSdkError( + 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" + return fixture +} + +private func expectPublicError( + _ operation: @autoclosure @escaping () async throws -> T, + equals expected: PublicErrorContract, + sourceLocation: SourceLocation = #_sourceLocation +) async { + let actual = await publicError(operation) + #expect(actual == expected, sourceLocation: sourceLocation) +} + +private func publicError( + _ operation: () async throws -> T, + sourceLocation: SourceLocation = #_sourceLocation +) async -> PublicErrorContract { + do { + _ = try await operation() + Issue.record("Expected public SDK error", sourceLocation: sourceLocation) + return PublicErrorContract( + code: nil, + operation: nil, + message: nil, + status: nil, + retryable: nil, + txnId: nil, + upstreamError: nil + ) + } catch { + return serialize(error) + } +} + +private func serialize(_ error: any Error) -> PublicErrorContract { + guard let sdkError = error as? OmsSdkError else { + return PublicErrorContract( + code: nil, + operation: nil, + message: error.localizedDescription, + status: nil, + retryable: nil, + txnId: nil, + upstreamError: nil + ) + } + + return serialize(sdkError) +} + +private func serialize(_ error: OmsSdkError) -> PublicErrorContract { + PublicErrorContract( + code: error.code, + operation: error.operation, + message: error.localizedDescription, + status: error.status, + retryable: error.retryable, + txnId: error.txnId, + upstreamError: error.upstreamError.map { + SerializedUpstreamError( + service: $0.service, + name: $0.name, + code: $0.code, + message: $0.message, + status: $0.status + ) + } + ) +} + +private func error( + code: OmsSdkErrorCode, + operation: OmsSdkOperation, + message: String, + status: Int? = nil, + retryable: Bool? = nil, + txnId: String? = nil, + upstreamError: SerializedUpstreamError? = nil +) -> PublicErrorContract { + PublicErrorContract( + code: code, + operation: operation, + message: message, + status: status, + retryable: retryable, + txnId: txnId, + upstreamError: upstreamError + ) +} + +private func upstream( + service: OmsUpstreamService, + name: String? = nil, + code: String? = nil, + message: String? = nil, + status: Int? = nil +) -> SerializedUpstreamError { + SerializedUpstreamError( + service: service, + name: name, + code: code, + message: message, + status: status + ) +} + +private struct PublicErrorContract: Equatable { + let code: OmsSdkErrorCode? + let operation: OmsSdkOperation? + let message: String? + let status: Int? + let retryable: Bool? + let txnId: String? + let upstreamError: SerializedUpstreamError? +} + +private struct SerializedUpstreamError: Equatable { + let service: OmsUpstreamService? + let name: String? + let code: String? + let message: String? + let status: Int? +} From 8e22baa581c12a40acf1c3bbf2c361b4751af3a3 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 30 Jun 2026 15:09:21 +0300 Subject: [PATCH 3/7] docs: document public error contracts --- API.md | 120 ++++++++++++++++++++++++++++++++++------ README.md | 21 ++++++- TESTING.md | 15 +++++ docs/error-contracts.md | 74 +++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 docs/error-contracts.md diff --git a/API.md b/API.md index 50c4023..ffbd5c7 100644 --- a/API.md +++ b/API.md @@ -17,6 +17,8 @@ - [OmsSdkError](#omssdkerror) - [OmsSdkErrorCode](#omssdkerrorcode) - [OmsSdkOperation](#omssdkoperation) + - [OmsUpstreamService](#omsupstreamservice) + - [OmsUpstreamError](#omsupstreamerror) - [TransactionError](#transactionerror) - [SendTransactionResponse](#sendtransactionresponse) - [TransactionMode](#transactionmode) @@ -799,12 +801,13 @@ symbol for native fee options. ### OmsSdkError ```swift -struct OmsSdkError: Error, LocalizedError, Sendable { +struct OmsSdkError: Error, LocalizedError, @unchecked Sendable { let code: OmsSdkErrorCode let operation: OmsSdkOperation? let status: Int? let txnId: String? - let retryable: Bool + let retryable: Bool? + let upstreamError: OmsUpstreamError? let underlyingError: (any Error)? } ``` @@ -812,9 +815,23 @@ struct OmsSdkError: Error, LocalizedError, Sendable { Public `WalletClient` and `IndexerClient` methods normalize recoverable SDK failures to `OmsSdkError`. Use `code` for stable app handling, `operation` for logging and analytics, `status` for HTTP-backed failures, `txnId` for -transaction status lookup failures, and `retryable` for retry UI. The -`underlyingError` preserves lower-level details such as `WebRPCError`, -`TransactionError`, or decoding/transport errors. +transaction recovery, and `retryable == true` for retry UI. `retryable` is +nullable because not every error family has meaningful retry semantics. + +`upstreamError` is normalized diagnostic detail from a remote OMS service +response, malformed remote response, or transport failure. It is present for +WaaS and Indexer failures that crossed a remote/transport boundary, and absent +for local session, selection, validation, OIDC state, and fee-selection errors. +Branch application behavior on SDK-level `code`; use `upstreamError` for logs +and service-specific troubleshooting. + +`underlyingError` is Swift-local diagnostic context. It is present when the SDK +wraps a lower-level Swift error such as `WebRPCError`, `WebRPCTransportError`, +`TransactionError`, `HttpError`, `URLError`, or a decoding error. It can be +absent for deliberate local SDK errors such as missing session and stale wallet +selection, and for manually constructed `OmsSdkError` values unless the caller +supplies it. Do not serialize or depend on `underlyingError` for cross-SDK +behavior. `PendingWalletSelection` validation failures, such as stale selections or unavailable wallet IDs, also throw `OmsSdkError`. @@ -829,9 +846,15 @@ do { case .sessionMissing, .sessionExpired: // Prompt the user to sign in again. break - case .httpError where error.retryable: + case .httpError where error.retryable == true: // Show retry UI. break + case .transactionExecutionUnconfirmed: + // Preserve error.txnId and avoid blindly resending the write. + break + case .transactionStatusLookupFailed: + // Retry getTransactionStatus with error.txnId. + break default: // Show a generic SDK error. break @@ -852,21 +875,85 @@ enum OmsSdkErrorCode: String, Sendable { case walletSelectionStale = "OMS_WALLET_SELECTION_STALE" case walletSelectionUnavailable = "OMS_WALLET_SELECTION_UNAVAILABLE" case walletSelectionInFlight = "OMS_WALLET_SELECTION_IN_FLIGHT" + case transactionExecutionUnconfirmed = "OMS_TRANSACTION_EXECUTION_UNCONFIRMED" case transactionStatusLookupFailed = "OMS_TRANSACTION_STATUS_LOOKUP_FAILED" case validationError = "OMS_VALIDATION_ERROR" } ``` +`OMS_AUTH_COMMITMENT_CONSUMED` means the OTP/OIDC auth commitment has already +been used. Restart the auth flow before retrying. + +`OMS_TRANSACTION_EXECUTION_UNCONFIRMED` means transaction preparation succeeded +and produced a `txnId`, but the execute request failed before the SDK could +confirm whether the transaction was submitted. Do not blindly resend the same +write solely because the upstream failure looked temporary. + +`OMS_TRANSACTION_STATUS_LOOKUP_FAILED` means the transaction was submitted, but +post-submit status polling failed. The error includes `txnId` when available and +is retryable by checking status again with `getTransactionStatus(txnId:)`. + ### OmsSdkOperation ```swift -enum OmsSdkOperation: String, Sendable +enum OmsSdkOperation: String, Sendable { + case pendingWalletSelection = "wallet.pendingWalletSelection" + case pendingWalletSelectionSelectWallet = "wallet.pendingWalletSelection.selectWallet" + case pendingWalletSelectionCreateAndSelectWallet = "wallet.pendingWalletSelection.createAndSelectWallet" + case walletStartEmailAuth = "wallet.startEmailAuth" + case walletCompleteEmailAuth = "wallet.completeEmailAuth" + case walletSignInWithOidcIdToken = "wallet.signInWithOidcIdToken" + case walletStartOidcRedirectAuth = "wallet.startOidcRedirectAuth" + case walletHandleOidcRedirectCallback = "wallet.handleOidcRedirectCallback" + case walletUseWallet = "wallet.useWallet" + case walletCreateWallet = "wallet.createWallet" + case walletListWallets = "wallet.listWallets" + case walletSignOut = "wallet.signOut" + case walletListAccess = "wallet.listAccess" + case walletListAccessPage = "wallet.listAccessPage" + case walletListAccessPages = "wallet.listAccessPages" + case walletGetIdToken = "wallet.getIdToken" + case walletRevokeAccess = "wallet.revokeAccess" + case walletSignMessage = "wallet.signMessage" + case walletSignTypedData = "wallet.signTypedData" + case walletIsValidMessageSignature = "wallet.isValidMessageSignature" + case walletIsValidTypedDataSignature = "wallet.isValidTypedDataSignature" + case walletSendTransaction = "wallet.sendTransaction" + case walletCallContract = "wallet.callContract" + case walletExecute = "wallet.execute" + case walletGetTransactionStatus = "wallet.getTransactionStatus" + case walletTransactionStatus = "wallet.transactionStatus" + case indexerGetBalances = "indexer.getBalances" + case indexerGetTransactionHistory = "indexer.getTransactionHistory" +} +``` + +Use `operation.rawValue` when logging SDK failures. + +### OmsUpstreamService + +```swift +enum OmsUpstreamService: String, Sendable { + case waas = "Waas" + case indexer = "Indexer" +} +``` + +### OmsUpstreamError + +```swift +struct OmsUpstreamError: Equatable, Sendable { + let service: OmsUpstreamService + let name: String? + let code: String? + let message: String? + let status: Int? +} ``` -Stable operation identifiers such as `wallet.sendTransaction`, -`wallet.completeEmailAuth`, `indexer.getBalances`, and -`indexer.getTransactionHistory`. Use -`operation.rawValue` when logging SDK failures. +`name` and `code` are service-specific. Indexer non-JSON HTTP failures use a +sanitized fallback message instead of exposing raw HTML or text response bodies. +WaaS non-JSON failures are normalized as `WebrpcBadResponse`. ### TransactionError @@ -880,11 +967,12 @@ enum TransactionError: Error { } ``` -Transaction-flow detail cases preserved under `OmsSdkError.underlyingError`. -`noFeeOptionsAvailable` is used when an unsponsored transaction has no fee -options, and `noFeeOptionSelected` is used when a custom selector does not -return a selection for an unsponsored transaction. Terminal non-executed -statuses use `transactionFailed`. A normal pending polling timeout returns +Transaction-flow detail cases may be preserved under +`OmsSdkError.underlyingError`. `noFeeOptionsAvailable` is used when an +unsponsored transaction has no fee options, and `noFeeOptionSelected` is used +when a custom selector does not return a selection for an unsponsored +transaction. Terminal non-executed statuses use `transactionFailed`. A normal +pending polling timeout returns `SendTransactionResponse(status: .pending, txnHash: nil)` instead of throwing. `missingTransactionHash` and `pollingTimedOut` remain public compatibility cases. diff --git a/README.md b/README.md index 6d00b25..4b563a5 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,19 @@ let txResult = try await oms.wallet.callContract( ### Handle SDK Errors +Public methods throw `OmsSdkError` with stable fields such as `code`, +`operation`, `status`, nullable `retryable`, and `txnId`. When a failure comes +from a remote OMS service response or transport failure, `upstreamError` +contains normalized WaaS or Indexer detail for logging. Application logic should +usually branch on `code`. + +For transaction writes, `.transactionExecutionUnconfirmed` means the SDK has a +`txnId` from preparation, but execute failed before the SDK could confirm +whether the transaction was submitted; do not blindly resend the same write. +`.transactionStatusLookupFailed` means the transaction was submitted, but status +polling failed, so retry status lookup with the returned `txnId`. `retryable` +describes the failed SDK operation, not the whole user intent. + ```swift let value = try parseUnits(value: "1", decimals: 18) do { @@ -461,16 +474,20 @@ do { switch error.code { case .sessionMissing, .sessionExpired: print("Sign in again") - case .httpError where error.retryable: + case .httpError where error.retryable == true: print("Retry:", error.localizedDescription) + case .transactionExecutionUnconfirmed: + print("Execution unconfirmed:", error.txnId ?? "unknown") case .transactionStatusLookupFailed: print("Transaction status lookup failed:", error.txnId ?? "unknown") default: - print("OMS SDK error:", error.localizedDescription) + print("OMS SDK error:", error.localizedDescription, error.upstreamError as Any) } } ``` +See [Public Error Contracts](docs/error-contracts.md) for the full SDK matrix. + ### Query Token Balances ```swift diff --git a/TESTING.md b/TESTING.md index 375e1c8..8793a93 100644 --- a/TESTING.md +++ b/TESTING.md @@ -19,6 +19,21 @@ Current test files: - `RequestsTests.swift` — HTTP request building and signing - `MockWalletTests.swift` — wallet auth and session logic with mocked dependencies - `IndexerTests.swift` — indexer client pagination and response handling +- `WaasRequestSigningTests.swift` — generated WaaS request payload signing +- `PublicErrorContractsTests.swift` — public `OmsSdkError` field, upstream, and recovery contracts + +## Public error contract tests + +`PublicErrorContractsTests.swift` is the centralized owner for app-facing SDK +error behavior. It serializes `OmsSdkError` into stable public fields: +`code`, `operation`, `message`, `status`, nullable `retryable`, `txnId`, and +`upstreamError`. + +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. ## Integration tests diff --git a/docs/error-contracts.md b/docs/error-contracts.md new file mode 100644 index 0000000..8d8e23c --- /dev/null +++ b/docs/error-contracts.md @@ -0,0 +1,74 @@ +# Public Error Contracts + +This document is the audit surface for Swift SDK error behavior. It records which +public runtime surfaces can fail, which structured `OmsSdkError` shape apps +should see, what recovery decision the error supports, whether `upstreamError` +should be present, and which tests own the contract. + +## Terms + +- `code` is the stable app-facing compatibility field. Branch on + `OmsSdkErrorCode` raw values for durable app behavior. +- `operation` identifies the public SDK operation that failed. Use + `operation.rawValue` for logs and analytics. +- `retryable` is nullable. When it is non-nil, it describes the failed SDK + operation, not the whole user intent. For example, retryable transaction + status polling means retry status lookup; it does not mean blindly resend the + original transaction write. +- `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. +- `underlyingError` is Swift-local diagnostic context. It is present when the + SDK wraps a lower-level Swift error such as `WebRPCError`, + `WebRPCTransportError`, `TransactionError`, `HttpError`, `URLError`, or a + decoding error. It can be absent for deliberate local SDK errors such as + missing session and stale wallet selection, and for manually constructed + `OmsSdkError` values unless the caller supplies it. Do not serialize or depend + on `underlyingError` for cross-SDK behavior. +- `OMS_TRANSACTION_EXECUTION_UNCONFIRMED` means transaction preparation + succeeded and produced a `txnId`, but the execute request failed before the + SDK could confirm whether the transaction was submitted. Do not blindly resend + the write. +- `OMS_TRANSACTION_STATUS_LOOKUP_FAILED` means the transaction was submitted, + but post-submit status polling failed. Retry by checking transaction status + with the returned `txnId`. + +## Maintenance Approach + +- Update this matrix, `Tests/Swift SDKTests/PublicErrorContractsTests.swift`, + `API.md`, 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. +- Public runtime methods should own runtime error contract coverage. Only assert + manually constructed `OmsSdkError` values when the initializer or field shape + itself is the unit under test. +- Keychain, signer, and storage classes are internal platform boundaries in this + SDK. Cover their failures in focused tests unless a failure is intentionally + normalized through a documented public `OmsSdkError`. +- Serialized contract changes are not automatically regressions. Decide whether + the new error shape is the intended public contract: if correct, update the + assertion and related docs; if accidental, fix the implementation. +- Treat message changes as user-visible API changes, even when `code` and + recovery behavior are unchanged. + +## SDK Matrix + +| Public surface | Failure family | User-facing error | Recovery meaning | `upstreamError` | Covering test | +|---|---|---|---|---|---| +| `oms.wallet.startEmailAuth`, representative WaaS methods | WaaS transport failure | `OmsSdkError`, `.requestFailed`, operation-specific, `retryable == true` for transport failures | Retry the same read/auth request when appropriate | Present | `PublicErrorContractsTests.swift` | +| `oms.wallet.completeEmailAuth` | WaaS domain error | SDK-specific code such as `.authCommitmentConsumed` | Follow the SDK code; for consumed commitments, restart auth | Present | `PublicErrorContractsTests.swift` | +| `oms.wallet.*`, representative WaaS methods | WaaS HTTP error | `OmsSdkError`, `.httpError`, `status`, `retryable == true` for 5xx | Use SDK code/status for branching; log upstream detail | Present | `PublicErrorContractsTests.swift` | +| `oms.wallet.completeEmailAuth` and `PendingWalletSelection` actions | Local auth/session/selection state | `.sessionMissing`, `.walletSelectionStale`, or `.walletSelectionUnavailable` | Fix local flow state or restart auth; no remote diagnostics are expected | Absent | `PublicErrorContractsTests.swift` | +| OIDC redirect and ID-token auth methods | Local OIDC config, callback, storage, or state mismatch | `.sessionMissing`, `.validationError`, or failed OIDC result containing `OmsSdkError` | Fix redirect config/state or restart OIDC flow | Absent | `PublicErrorContractsTests.swift` | +| Protected wallet methods: `getIdToken`, `signMessage`, `signTypedData`, `sendTransaction`, `callContract`, `getTransactionStatus`, `listAccessPage`, `listAccessPages`, `revokeAccess` | Missing or expired local session | `.sessionMissing` or `.sessionExpired` | Authenticate again or recover local session; no remote request was made | Absent | `PublicErrorContractsTests.swift` | +| `oms.wallet.signMessage`, `signTypedData`, `getIdToken`, `sendTransaction`, `callContract` | SDK-local validation or fee-selection failure | `.validationError` | Correct parameters or local fee selection; do not retry as an upstream outage | Absent | `PublicErrorContractsTests.swift` | +| `oms.wallet.isValidMessageSignature`, `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` | +| `oms.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` | +| `oms.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` | +| `oms.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` | +| `oms.wallet.listAccessPage`, `listAccessPages`, `revokeAccess` | WaaS access backend failure | `.httpError`, `.requestFailed`, or `.invalidResponse` with access operation | Retry based on SDK code/status; log upstream detail | Present | `PublicErrorContractsTests.swift` | +| `oms.indexer.getBalances`, `getTransactionHistory` | 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` | +| `oms.indexer.getBalances`, `getTransactionHistory` | IndexerGateway non-JSON HTTP body | `.httpError` with sanitized message | Do not expose raw upstream HTML/text bodies; log normalized detail | Present, sanitized | `PublicErrorContractsTests.swift` | +| Public `OmsSdkError` initializer and upstream fields | Error field contract | Stable public fields on constructed errors | Use only when the initializer/field shape is the unit under test | As constructed | `PublicErrorContractsTests.swift` | From 2e43ab51933de8281196e0658a6370d9a7d894a6 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 30 Jun 2026 15:22:46 +0300 Subject: [PATCH 4/7] fix: update demo error details --- Examples/sdk-demo/oms-sdk-demo/AppError.swift | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Examples/sdk-demo/oms-sdk-demo/AppError.swift b/Examples/sdk-demo/oms-sdk-demo/AppError.swift index 588e636..0262926 100644 --- a/Examples/sdk-demo/oms-sdk-demo/AppError.swift +++ b/Examples/sdk-demo/oms-sdk-demo/AppError.swift @@ -86,7 +86,11 @@ private func errorMessage(for error: OmsSdkError) -> String { if let status = error.status { details.append("HTTP status: \(status)") } - details.append("Retryable: \(error.retryable ? "yes" : "no")") + details.append("Retryable: \(retryableDescription(error.retryable))") + + if let upstreamError = error.upstreamError { + details.append(contentsOf: upstreamDetails(upstreamError)) + } if let webRPCError = webRPCError(from: error.underlyingError) { details.append(contentsOf: webRPCDetails(webRPCError)) @@ -101,6 +105,17 @@ private func errorMessage(for error: OmsSdkError) -> String { return sections.joined(separator: "\n\n") } +private func retryableDescription(_ retryable: Bool?) -> String { + switch retryable { + case .some(true): + return "yes" + case .some(false): + return "no" + case nil: + return "not specified" + } +} + private func errorMessage(for error: WebRPCError) -> String { let details = webRPCDetails(error).joined(separator: "\n") let diagnosticSuffix = details.isEmpty ? "" : "\n\n\(details)" @@ -187,6 +202,25 @@ private func webRPCError(from error: (any Error)?) -> WebRPCError? { return nil } +private func upstreamDetails(_ error: OmsUpstreamError) -> [String] { + var details = ["Upstream service: \(error.service.rawValue)"] + + if let name = error.name, !name.isEmpty { + details.append("Upstream error: \(name)") + } + if let code = error.code, !code.isEmpty { + details.append("Upstream code: \(code)") + } + if let message = error.message, !message.isEmpty { + details.append("Upstream message: \(message)") + } + if let status = error.status { + details.append("Upstream status: \(status)") + } + + return details +} + private func webRPCDetails(_ error: WebRPCError) -> [String] { var details = [ "WebRPC error: \(error.error)", From 000d45e01e56f4a47b7c2f130eefdfbc575990e5 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 30 Jun 2026 15:26:15 +0300 Subject: [PATCH 5/7] test: stabilize session expiry timer --- Tests/Swift SDKTests/MockWalletTests.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Tests/Swift SDKTests/MockWalletTests.swift b/Tests/Swift SDKTests/MockWalletTests.swift index 5f7421d..e39ff72 100644 --- a/Tests/Swift SDKTests/MockWalletTests.swift +++ b/Tests/Swift SDKTests/MockWalletTests.swift @@ -200,14 +200,27 @@ import Testing fixture.client.onSessionExpired = { event in expiredEvent = event } - try await Task.sleep(nanoseconds: 200_000_000) + let event = try await waitForSessionExpiredEvent { expiredEvent } - #expect(expiredEvent?.session.walletAddress == storedCredentials.walletAddress) - #expect(expiredEvent?.session.sessionEmail == "user@example.com") + #expect(event?.session.walletAddress == storedCredentials.walletAddress) + #expect(event?.session.sessionEmail == "user@example.com") #expect(fixture.client.session == SessionState(walletAddress: nil)) #expect(try fixture.storedCredentials()?.walletId == storedCredentials.walletId) } +private func waitForSessionExpiredEvent( + _ event: () -> SessionExpiredEvent?, + attempts: Int = 40 +) async throws -> SessionExpiredEvent? { + for _ in 0.. Date: Tue, 30 Jun 2026 15:41:21 +0300 Subject: [PATCH 6/7] chore(release): 0.1.0-alpha.4 --- README.md | 2 +- oms-client-swift-sdk.podspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4b563a5..069bb30 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ https://github.com/0xsequence/swift-sdk.git Add the pod to your `Podfile`: ```ruby -pod 'oms-client-swift-sdk', '0.1.0-alpha.3' +pod 'oms-client-swift-sdk', '0.1.0-alpha.4' ``` ## Quick Start diff --git a/oms-client-swift-sdk.podspec b/oms-client-swift-sdk.podspec index b6bdef0..6f5b2e5 100644 --- a/oms-client-swift-sdk.podspec +++ b/oms-client-swift-sdk.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "oms-client-swift-sdk" - s.version = "0.1.0-alpha.3" + s.version = "0.1.0-alpha.4" s.summary = "OMS Client Swift SDK." s.description = <<-DESC OMS Client Swift SDK provides email, OIDC ID-token, and OIDC redirect wallet authentication, From c8bc8bae28de463c48efdf403ea6afa509eba7d8 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Tue, 30 Jun 2026 15:48:46 +0300 Subject: [PATCH 7/7] fix: address error contract review feedback --- Sources/Swift SDK/Clients/IndexerClient.swift | 13 ++++++ Sources/Swift SDK/Models/OmsSdkError.swift | 9 +++- .../PublicErrorContractsTests.swift | 44 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Sources/Swift SDK/Clients/IndexerClient.swift b/Sources/Swift SDK/Clients/IndexerClient.swift index 956e42c..c219379 100644 --- a/Sources/Swift SDK/Clients/IndexerClient.swift +++ b/Sources/Swift SDK/Clients/IndexerClient.swift @@ -148,6 +148,19 @@ public final class IndexerClient { ) } catch let error as CancellationError { throw error + } catch let error as HttpError { + guard case .transport = error else { + throw error + } + let upstreamError = indexerTransportUpstreamError(error) + throw OmsSdkError( + code: .requestFailed, + message: upstreamError.message ?? error.localizedDescription, + operation: operation, + retryable: true, + upstreamError: upstreamError, + underlyingError: error + ) } catch { let upstreamError = indexerTransportUpstreamError(error) throw OmsSdkError( diff --git a/Sources/Swift SDK/Models/OmsSdkError.swift b/Sources/Swift SDK/Models/OmsSdkError.swift index 183cda8..ac96f5b 100644 --- a/Sources/Swift SDK/Models/OmsSdkError.swift +++ b/Sources/Swift SDK/Models/OmsSdkError.swift @@ -263,7 +263,7 @@ private extension WebRPCError { ) } - if kind == .webrpcBadResponse || kind == .unknown && code == WebRPCErrorKind.unknown.code { + if kind == .webrpcBadResponse || (kind == .unknown && code == WebRPCErrorKind.unknown.code) { return OmsSdkError( code: .invalidResponse, message: normalizedMessage, @@ -324,7 +324,12 @@ private extension WebRPCError { } switch kind { - case .webrpcBadRoute, .webrpcBadMethod, .webrpcBadRequest, .webrpcBadResponse: + case .webrpcBadRoute, + .webrpcBadMethod, + .webrpcBadRequest, + .webrpcBadResponse, + .webrpcServerPanic, + .webrpcInternalError: return true default: return error == "WebrpcBadResponse" diff --git a/Tests/Swift SDKTests/PublicErrorContractsTests.swift b/Tests/Swift SDKTests/PublicErrorContractsTests.swift index 75d1740..76dc5f7 100644 --- a/Tests/Swift SDKTests/PublicErrorContractsTests.swift +++ b/Tests/Swift SDKTests/PublicErrorContractsTests.swift @@ -87,6 +87,35 @@ import Testing ) ) + let serverPanicFixture = makeRestoredWalletClient() + serverPanicFixture.transport.enqueueRawHTTPError( + statusCode: 500, + body: Data( + """ + {"error":"WebrpcServerPanic","code":-6,"msg":"server panic","status":500} + """.utf8 + ), + for: WaasAPI.SignMessage.urlPath + ) + + await expectPublicError( + try await serverPanicFixture.client.signMessage(network: .polygon, message: "hello"), + equals: error( + code: .httpError, + operation: .walletSignMessage, + message: "server panic", + status: 500, + retryable: true, + upstreamError: upstream( + service: .waas, + name: "WebrpcServerPanic", + code: "-6", + message: "server panic", + status: 500 + ) + ) + ) + let nonJsonFixture = makeRestoredWalletClient() nonJsonFixture.transport.enqueueRawHTTPError( statusCode: 502, @@ -623,6 +652,21 @@ import Testing ) ) ) + + let invalidUrlClient = IndexerClient( + publishableKey: "test-key", + environment: OMSClientEnvironment(indexerGatewayUrl: "http://[::1") + ) + let invalidUrlFailure = await publicError { + try await invalidUrlClient.getBalances( + GetBalancesParams(walletAddress: "0xwallet", networks: [.polygon]) + ) + } + #expect(invalidUrlFailure.code == .validationError) + #expect(invalidUrlFailure.operation == .indexerGetBalances) + #expect(invalidUrlFailure.status == nil) + #expect(invalidUrlFailure.retryable == nil) + #expect(invalidUrlFailure.upstreamError == nil) } @Test func TestPublicErrorContractsConstructedErrorFieldsAreStable() {