diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..d2a44e1 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,15 @@ +Copyright 2024 Circle Internet Group, Inc. All rights reserved. + +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at. + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/CircleModularWalletsCore/Resources/CircleModularWalletsCore.h b/CircleModularWalletsCore/Resources/CircleModularWalletsCore.h new file mode 100644 index 0000000..8366862 --- /dev/null +++ b/CircleModularWalletsCore/Resources/CircleModularWalletsCore.h @@ -0,0 +1,29 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +//! Project version number for CircleModularWalletsCore. +FOUNDATION_EXPORT double CircleModularWalletsCoreVersionNumber; + +//! Project version string for CircleModularWalletsCore. +FOUNDATION_EXPORT const unsigned char CircleModularWalletsCoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/CircleModularWalletsCore/Resources/Info.plist b/CircleModularWalletsCore/Resources/Info.plist new file mode 100644 index 0000000..21ebb92 --- /dev/null +++ b/CircleModularWalletsCore/Resources/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleShortVersionString + 0.0.2 + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + NSHumanReadableCopyright + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + + diff --git a/CircleModularWalletsCore/Resources/PrivacyInfo.xcprivacy b/CircleModularWalletsCore/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a0129f5 --- /dev/null +++ b/CircleModularWalletsCore/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,29 @@ + + + + + NSPrivacyAccessedAPITypes + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/CircleModularWalletsCore/Sources/APIs/Bundler/BundlerRpcApi.swift b/CircleModularWalletsCore/Sources/APIs/Bundler/BundlerRpcApi.swift new file mode 100644 index 0000000..92c09e0 --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Bundler/BundlerRpcApi.swift @@ -0,0 +1,373 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +protocol BundlerRpcApi: PublicRpcApi, PaymasterRpcApi { + + func estimateUserOperationGas( + transport: Transport, + userOp: T, + entryPoint: EntryPoint + ) async throws -> EstimateUserOperationGasResult + + func getChainId(transport: Transport) async throws -> Int + + func getSupportedEntryPoints(transport: Transport) async throws -> [String] + + func getUserOperation(transport: Transport, userOpHash: String) async throws -> GetUserOperationResult + + func getUserOperationReceipt(transport: Transport, userOpHash: String) async throws -> GetUserOperationReceiptResult + + func prepareUserOperation( + transport: Transport, + account: SmartAccount, + calls: [EncodeCallDataArg]?, + partialUserOp: UserOperationV07, + paymaster: Paymaster?, + bundlerClient: BundlerClient, + estimateFeesPerGas: ((SmartAccount, BundlerClient, UserOperationV07) async -> EstimateFeesPerGasResult)? + ) async throws -> UserOperationV07 + + func sendUserOperation( + transport: Transport, + partialUserOp: UserOperationV07, + entryPointAddress: String + ) async throws -> String + + func waitForUserOperationReceipt( + transport: Transport, + userOpHash: String, + pollingInterval: Int, + retryCount: Int, + timeout: Int? + ) async throws -> GetUserOperationReceiptResult +} + +extension BundlerRpcApi { + + func estimateUserOperationGas( + transport: Transport, + userOp: T, + entryPoint: EntryPoint + ) async throws -> EstimateUserOperationGasResult { + do { + let params = [AnyEncodable(userOp), + AnyEncodable(entryPoint.address)] + let req = RpcRequest(method: "eth_estimateUserOperationGas", params: params) + let response = try await transport.request(req) as RpcResponse + return response.result + + } catch let error as BaseError { + throw ErrorUtils.getUserOperationExecutionError(err: error, userOp: userOp) + + } catch { + let baseError = BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + throw ErrorUtils.getUserOperationExecutionError(err: baseError, userOp: userOp) + } + } + + func getChainId(transport: Transport) async throws -> Int { + let chainIdHex = try await self._getChainId(transport: transport) + guard let chainId = HexUtils.hexToInt(hex: chainIdHex) else { + throw CommonError.invalidHexString + } + return chainId + } + + func getSupportedEntryPoints(transport: Transport) async throws -> [String] { + let req = RpcRequest(method: "eth_supportedEntryPoints", params: emptyParams) + let response = try await transport.request(req) as RpcResponse<[String]> + return response.result + } + + func getUserOperation(transport: Transport, userOpHash: String) async throws -> GetUserOperationResult { + do { + let req = RpcRequest(method: "eth_getUserOperationByHash", params: [userOpHash]) + let response = try await transport.request(req) as RpcResponse + return response.result + + } catch { + throw UserOperationNotFoundError(hash: userOpHash) + } + } + + func getUserOperationReceipt(transport: Transport, userOpHash: String) async throws -> GetUserOperationReceiptResult { + do { + let req = RpcRequest(method: "eth_getUserOperationReceipt", params: [userOpHash]) + let response = try await transport.request(req) as RpcResponse + return response.result + + } catch { + throw UserOperationReceiptNotFoundError(hash: userOpHash, cause: error) + } + } + + func prepareUserOperation( + transport: Transport, + account: SmartAccount, + calls: [EncodeCallDataArg]?, + partialUserOp: UserOperationV07, + paymaster: Paymaster?, + bundlerClient: BundlerClient, + estimateFeesPerGas: ((SmartAccount, BundlerClient, UserOperationV07) async -> EstimateFeesPerGasResult)? + ) async throws -> UserOperationV07 { + do { + if let estimateGas = await account.userOperation?.estimateGas, + let r = await estimateGas(partialUserOp) { + partialUserOp.preVerificationGas = r.preVerificationGas ?? partialUserOp.preVerificationGas + partialUserOp.verificationGasLimit = r.verificationGasLimit ?? partialUserOp.verificationGasLimit + partialUserOp.callGasLimit = r.callGasLimit ?? partialUserOp.callGasLimit + partialUserOp.paymasterVerificationGasLimit = r.paymasterVerificationGasLimit ?? partialUserOp.paymasterVerificationGasLimit + partialUserOp.paymasterPostOpGasLimit = r.paymasterPostOpGasLimit ?? partialUserOp.paymasterPostOpGasLimit + } + + let userOp = partialUserOp.copy() + userOp.sender = account.getAddress() + + if let calls = calls { + let updatedCalls = getUpdatedCalls(calls: calls) + userOp.callData = account.encodeCalls(args: updatedCalls) + } + + if userOp.factory?.isEmpty ?? true || userOp.factoryData?.isEmpty ?? true { + if let arg = try await account.getFactoryArgs() { + userOp.factory = arg.0 + userOp.factoryData = arg.1 + } + } + + if partialUserOp.maxFeePerGas?.isZero ?? true || partialUserOp.maxPriorityFeePerGas?.isZero ?? true { + if estimateFeesPerGas == nil { + let defaultMaxFeePerGas = try UnitUtils.parseGweiToWei("3") + let defaultMaxPriorityFeePerGas = try UnitUtils.parseGweiToWei("1") + let two = BigInt(2) + + let fees = try? await self.estimateFeesPerGas( + transport: account.client.transport, + feeValuesType: .eip1559 + ) + + if partialUserOp.maxFeePerGas == nil { + userOp.maxFeePerGas = max(defaultMaxFeePerGas, + (fees?.maxFeePerGas ?? 0) * two) + } + + if partialUserOp.maxPriorityFeePerGas == nil { + userOp.maxPriorityFeePerGas = max(defaultMaxPriorityFeePerGas, + (fees?.maxPriorityFeePerGas ?? 0) * two) + } + + } else if let r = await estimateFeesPerGas?(account, bundlerClient, userOp) { + if partialUserOp.maxFeePerGas == nil { + userOp.maxFeePerGas = r.maxFeePerGas + } + if partialUserOp.maxPriorityFeePerGas == nil { + userOp.maxPriorityFeePerGas = r.maxPriorityFeePerGas + } + } + } + + if partialUserOp.nonce?.isZero ?? true { + userOp.nonce = try await Utils.getNonce(transport: account.client.transport, + address: account.getAddress(), + entryPoint: account.entryPoint) + } + + if partialUserOp.signature?.isEmpty ?? true { + userOp.signature = account.getStubSignature(userOp: partialUserOp) + } + + var isPaymasterPopulated = false + + if paymaster != nil { + let stubR: GetPaymasterStubDataResult? + if let truePaymaster = paymaster as? Paymaster.True { + stubR = try? await self.getPaymasterStubData( + transport: transport, + userOp: userOp, + entryPoint: account.entryPoint, + chainId: bundlerClient.chain.chainId, + context: truePaymaster.paymasterContext + ) + } else if let clientPaymaster = paymaster as? Paymaster.Client { + stubR = try? await self.getPaymasterStubData( + transport: clientPaymaster.client.transport, + userOp: userOp, + entryPoint: account.entryPoint, + chainId: bundlerClient.chain.chainId, + context: clientPaymaster.paymasterContext + ) + } else { + throw BaseError(shortMessage: "Unsupported paymaster type") + } + + isPaymasterPopulated = stubR?.isFinal ?? false + + userOp.paymaster = stubR?.paymaster + userOp.paymasterVerificationGasLimit = stubR?.paymasterVerificationGasLimit + userOp.paymasterPostOpGasLimit = stubR?.paymasterPostOpGasLimit + userOp.paymasterData = stubR?.paymasterData + } + + // If not all the gas properties are already populated, we will need to estimate the gas to fill the gas properties. + if userOp.preVerificationGas == nil || + userOp.verificationGasLimit == nil || + userOp.callGasLimit == nil || + (paymaster != nil && userOp.paymasterVerificationGasLimit == nil) || + (paymaster != nil && userOp.paymasterPostOpGasLimit == nil) { + + // Some Bundlers fail if nullish gas values are provided for gas estimation :') + // So we will need to set a default zeroish value. + let tmpUserOp = userOp.copy() + tmpUserOp.callGasLimit = .zero + tmpUserOp.preVerificationGas = .zero + + if paymaster != nil { + tmpUserOp.paymasterVerificationGasLimit = .zero + tmpUserOp.paymasterPostOpGasLimit = .zero + } else { + tmpUserOp.paymasterVerificationGasLimit = nil + tmpUserOp.paymasterPostOpGasLimit = nil + } + + let r = try await estimateUserOperationGas( + transport: transport, + userOp: tmpUserOp, + entryPoint: account.entryPoint + ) + userOp.callGasLimit = userOp.callGasLimit ?? r.callGasLimit + userOp.preVerificationGas = userOp.preVerificationGas ?? r.preVerificationGas + userOp.verificationGasLimit = userOp.verificationGasLimit ?? r.verificationGasLimit + userOp.paymasterPostOpGasLimit = userOp.paymasterPostOpGasLimit ?? r.paymasterPostOpGasLimit + userOp.paymasterVerificationGasLimit = userOp.paymasterVerificationGasLimit ?? r.paymasterVerificationGasLimit + } + + if paymaster != nil, !isPaymasterPopulated { + let r: GetPaymasterDataResult? + if let truePaymaster = paymaster as? Paymaster.True { + r = try? await self.getPaymasterData( + transport: transport, + userOp: userOp, + entryPoint: account.entryPoint, + chainId: bundlerClient.chain.chainId, + context: truePaymaster.paymasterContext + ) + } else if let clientPaymaster = paymaster as? Paymaster.Client { + r = try? await self.getPaymasterData( + transport: clientPaymaster.client.transport, + userOp: userOp, + entryPoint: account.entryPoint, + chainId: bundlerClient.chain.chainId, + context: clientPaymaster.paymasterContext + ) + } else { + throw BaseError(shortMessage: "Unsupported paymaster type") + } + + userOp.paymaster = r?.paymaster ?? userOp.paymaster + userOp.paymasterVerificationGasLimit = r?.paymasterVerificationGasLimit ?? userOp.paymasterVerificationGasLimit + userOp.paymasterPostOpGasLimit = r?.paymasterPostOpGasLimit ?? userOp.paymasterPostOpGasLimit + userOp.paymasterData = r?.paymasterData ?? userOp.paymasterData + } + + return userOp + + } catch let error as BaseError { + throw error + + } catch { + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } + + func sendUserOperation( + transport: Transport, + partialUserOp: UserOperationV07, + entryPointAddress: String + ) async throws -> String { + do { + let req = RpcRequest(method: "eth_sendUserOperation", + params: [AnyEncodable(partialUserOp), + AnyEncodable(entryPointAddress)]) + let response = try await transport.request(req) as RpcResponse + + return response.result + + } catch let error as BaseError { + throw ErrorUtils.getUserOperationExecutionError(err: error, userOp: partialUserOp) + + } catch { + let baseError = BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + throw ErrorUtils.getUserOperationExecutionError(err: baseError, userOp: partialUserOp) + } + } + + func waitForUserOperationReceipt( + transport: Transport, + userOpHash: String, + pollingInterval: Int, + retryCount: Int, + timeout: Int? + ) async throws -> GetUserOperationReceiptResult { + do { + let result = try await Utils.startPolling(pollingInterval: pollingInterval, + retryCount: retryCount, + timeout: timeout) { + try await getUserOperationReceipt(transport: transport, userOpHash: userOpHash) + } + return result + + } catch Utils.PollingError.timeout { + throw WaitForUserOperationReceiptTimeoutError(hash: userOpHash) + + } catch { + throw UserOperationReceiptNotFoundError(hash: userOpHash, cause: error) + } + } +} + +extension BundlerRpcApi { + + func getUpdatedCalls(calls: [EncodeCallDataArg]) -> [EncodeCallDataArg] { + let updatedCalls: [EncodeCallDataArg] = calls.map { call in + if let abiJson = call.abiJson, !abiJson.isEmpty, + let functionName = call.functionName, !functionName.isEmpty { + return EncodeCallDataArg( + to: call.to, + value: call.value, + data: Utils.encodeFunctionData( + functionName: functionName, + abiJson: abiJson, + args: call.args ?? [] + ), + abiJson: abiJson, + functionName: functionName, + args: call.args + ) + } else { + return call + } + } + return updatedCalls + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Bundler/BundlerRpcReqResp.swift b/CircleModularWalletsCore/Sources/APIs/Bundler/BundlerRpcReqResp.swift new file mode 100644 index 0000000..a2483ad --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Bundler/BundlerRpcReqResp.swift @@ -0,0 +1,186 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +/// Result for ``BundlerClient/estimateUserOperationGas(userOp:entryPoint:)`` +public struct EstimateUserOperationGasResult: Codable { + let preVerificationGas: BigInt? + let verificationGasLimit: BigInt? + let callGasLimit: BigInt? + let paymasterVerificationGasLimit: BigInt? + let paymasterPostOpGasLimit: BigInt? + + init(preVerificationGas: BigInt?, verificationGasLimit: BigInt?, callGasLimit: BigInt?, paymasterVerificationGasLimit: BigInt?, paymasterPostOpGasLimit: BigInt?) { + self.preVerificationGas = preVerificationGas + self.verificationGasLimit = verificationGasLimit + self.callGasLimit = callGasLimit + self.paymasterVerificationGasLimit = paymasterVerificationGasLimit + self.paymasterPostOpGasLimit = paymasterPostOpGasLimit + } + + enum CodingKeys: CodingKey { + case preVerificationGas + case verificationGasLimit + case callGasLimit + case paymasterVerificationGasLimit + case paymasterPostOpGasLimit + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeBigInt(self.preVerificationGas, forKey: .preVerificationGas) + try container.encodeBigInt(self.verificationGasLimit, forKey: .verificationGasLimit) + try container.encodeBigInt(self.callGasLimit, forKey: .callGasLimit) + try container.encodeBigInt(self.paymasterVerificationGasLimit, forKey: .paymasterVerificationGasLimit) + try container.encodeBigInt(self.paymasterPostOpGasLimit, forKey: .paymasterPostOpGasLimit) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.preVerificationGas = try container.decodeToBigInt(forKey: .preVerificationGas) + self.verificationGasLimit = try container.decodeToBigInt(forKey: .verificationGasLimit) + self.callGasLimit = try container.decodeToBigInt(forKey: .callGasLimit) + self.paymasterVerificationGasLimit = try container.decodeToBigInt(forKey: .paymasterVerificationGasLimit) + self.paymasterPostOpGasLimit = try container.decodeToBigInt(forKey: .paymasterPostOpGasLimit) + } +} + +/// Result for ``BundlerClient/getUserOperation(userOpHash:)`` +public struct GetUserOperationResult: Codable { + let blockHash: String? + let blockNumber: BigInt? + let transactionHash: String? + let entryPoint: String? + let userOperation: UserOperationType? + + enum CodingKeys: String, CodingKey { + case userOperation + case transactionHash + case entryPoint + case blockNumber + case blockHash + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(blockHash, forKey: .blockHash) + try container.encodeIfPresent(transactionHash, forKey: .transactionHash) + try container.encodeIfPresent(entryPoint, forKey: .entryPoint) + try container.encodeBigInt(blockNumber, forKey: .blockNumber) + + if case let .v07(userOp) = userOperation { + try container.encodeIfPresent(userOp, forKey: .userOperation) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + blockHash = try container.decodeIfPresent(String.self, forKey: .blockHash) + transactionHash = try container.decodeIfPresent(String.self, forKey: .transactionHash) + entryPoint = try container.decodeIfPresent(String.self, forKey: .entryPoint) + blockNumber = try container.decodeToBigInt(forKey: .blockNumber) + + if let userOp = try? container.decodeIfPresent(UserOperationV07.self, forKey: .userOperation) { + self.userOperation = .v07(userOp) + } else { + self.userOperation = nil + } + } +} + +/// Result for ``BundlerClient/getUserOperationReceipt(userOpHash:)`` +public struct GetUserOperationReceiptResult: Codable { + public let userOpHash: String? + public let sender: String? + public let nonce: BigInt? + public let actualGasCost: BigInt? + public let actualGasUsed: BigInt? + public let success: Bool? + public let paymaster: String? + public let logs: [Log]? + public let receipt: UserOperationReceipt + + enum CodingKeys: CodingKey { + case userOpHash + case sender + case nonce + case actualGasCost + case actualGasUsed + case success + case paymaster + case logs + case receipt + } + + public func encodeIfPresent(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.userOpHash, forKey: .userOpHash) + try container.encodeIfPresent(self.sender, forKey: .sender) + try container.encodeBigInt(self.nonce, forKey: .nonce) + try container.encodeBigInt(self.actualGasCost, forKey: .actualGasCost) + try container.encodeBigInt(self.actualGasUsed, forKey: .actualGasUsed) + try container.encodeIfPresent(self.success, forKey: .success) + try container.encodeIfPresent(self.paymaster, forKey: .paymaster) + try container.encodeIfPresent(self.logs, forKey: .logs) + try container.encode(self.receipt, forKey: .receipt) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.userOpHash = try container.decodeIfPresent(String.self, forKey: .userOpHash) + self.sender = try container.decodeIfPresent(String.self, forKey: .sender) + self.nonce = try container.decodeToBigInt(forKey: .nonce) + self.actualGasCost = try container.decodeToBigInt(forKey: .actualGasCost) + self.actualGasUsed = try container.decodeToBigInt(forKey: .actualGasUsed) + self.success = try container.decodeIfPresent(Bool.self, forKey: .success) + self.paymaster = try container.decodeIfPresent(String.self, forKey: .paymaster) + self.logs = try container.decodeIfPresent([GetUserOperationReceiptResult.Log].self, forKey: .logs) + self.receipt = try container.decode(GetUserOperationReceiptResult.UserOperationReceipt.self, forKey: .receipt) + } + + // https://github.com/wevm/viem/blob/e7431e88b0e8b83719c91f5a6a57da1a10076a1c/src/account-abstraction/types/userOperation.ts#L167 + public struct UserOperationReceipt: Codable { + let transactionHash: String? + let transactionIndex: String? + let blockHash: String? + let blockNumber: String? + let from: String? + let to: String? + let cumulativeGasUsed: String? + let gasUsed: String? + let logs: [Log]? + let logsBloom: String? + let status: String? + let effectiveGasPrice: String? + } + + // https://github.com/wevm/viem/blob/e7431e88b0e8b83719c91f5a6a57da1a10076a1c/src/types/log.ts#L15 + public struct Log: Codable { + let removed: Bool? + let logIndex: String? + let transactionIndex: String? + let transactionHash: String? + let blockHash: String? + let blockNumber: String? + let address: String? + let data: String? + let topics: [String]? + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Modular/ModularRpcApi.swift b/CircleModularWalletsCore/Sources/APIs/Modular/ModularRpcApi.swift new file mode 100644 index 0000000..923f34c --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Modular/ModularRpcApi.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol ModularRpcApi { + + func circleGetAddress( + transport: Transport, + req: CreateWalletRequest + ) async throws -> Wallet +} + +extension ModularRpcApi { + + func circleGetAddress( + transport: Transport, + req: CreateWalletRequest + ) async throws -> Wallet { + let req = RpcRequest(method: "circle_getAddress", params: [req]) + let response = try await transport.request(req) as RpcResponse + return response.result + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Modular/ModularRpcReqResp.swift b/CircleModularWalletsCore/Sources/APIs/Modular/ModularRpcReqResp.swift new file mode 100644 index 0000000..61899d9 --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Modular/ModularRpcReqResp.swift @@ -0,0 +1,80 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct CreateWalletRequest: Encodable { + let scaConfiguration: ScaConfiguration +} + +public struct CreateWalletResponse: Decodable { + var data: Data + + struct Data: Codable { + var wallets: [Wallet] + } +} + +public struct Wallet: Codable { + var id: String? + var address: String? + var blockchain: String? + var state: String? + var name: String? + var scaCore: String? + var scaConfiguration: ScaConfiguration? + var createDate: String? + var updateDate: String? + + func getInitCode() -> String? { + return scaConfiguration?.initCode + } +} + +struct ScaConfiguration: Codable { + let initialOwnershipConfiguration: InitialOwnershipConfiguration + let scaCore: String? + let initCode: String? + + struct InitialOwnershipConfiguration: Codable { + let ownershipContractAddress: String? + let weightedMultiSig: WeightedMultiSig? + + enum CodingKeys: String, CodingKey { + case ownershipContractAddress + case weightedMultiSig = "weightedMultisig" + } + + struct WeightedMultiSig: Codable { + let owners: [Owner]? + let webauthnOwners: [WebAuthnOwner]? + let thresholdWeight: Int? + + struct Owner: Codable { + let address: String + let weight: Int + } + + struct WebAuthnOwner: Codable { + let publicKeyX: String + let publicKeyY: String + let weight: Int + } + } + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Paymaster/PaymasterRpcApi.swift b/CircleModularWalletsCore/Sources/APIs/Paymaster/PaymasterRpcApi.swift new file mode 100644 index 0000000..25de231 --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Paymaster/PaymasterRpcApi.swift @@ -0,0 +1,101 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol PaymasterRpcApi { + + func getPaymasterData( + transport: Transport, + userOp: T, + entryPoint: EntryPoint, + chainId: Int, + context: [String: AnyEncodable]? + ) async throws -> GetPaymasterDataResult + + func getPaymasterStubData( + transport: Transport, + userOp: T, + entryPoint: EntryPoint, + chainId: Int, + context: [String: AnyEncodable]? + ) async throws -> GetPaymasterStubDataResult +} + +extension PaymasterRpcApi { + + func getPaymasterData( + transport: Transport, + userOp: T, + entryPoint: EntryPoint, + chainId: Int, + context: [String: AnyEncodable]? = nil + ) async throws -> GetPaymasterDataResult { + let userOp = userOp.copy() + if userOp.callGasLimit == nil { + userOp.callGasLimit = .zero + } + if userOp.verificationGasLimit == nil { + userOp.verificationGasLimit = .zero + } + if userOp.preVerificationGas == nil { + userOp.preVerificationGas = .zero + } + + let chainIdHexStr = HexUtils.intToHex(chainId) + var params = [AnyEncodable(userOp), + AnyEncodable(entryPoint.address), + AnyEncodable(chainIdHexStr)] + if let context { + params.append(AnyEncodable(context)) + } + let req = RpcRequest(method: "pm_getPaymasterData", params: params) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + func getPaymasterStubData( + transport: Transport, + userOp: T, + entryPoint: EntryPoint, + chainId: Int, + context: [String: AnyEncodable]? = nil + ) async throws -> GetPaymasterStubDataResult { + let userOp = userOp.copy() + if userOp.callGasLimit == nil { + userOp.callGasLimit = .zero + } + if userOp.verificationGasLimit == nil { + userOp.verificationGasLimit = .zero + } + if userOp.preVerificationGas == nil { + userOp.preVerificationGas = .zero + } + + let chainIdHexStr = HexUtils.intToHex(chainId) + var params = [AnyEncodable(userOp), + AnyEncodable(entryPoint.address), + AnyEncodable(chainIdHexStr)] + if let context { + params.append(AnyEncodable(context)) + } + let req = RpcRequest(method: "pm_getPaymasterStubData", params: params) + let response = try await transport.request(req) as RpcResponse + return response.result + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Paymaster/PaymasterRpcReqResp.swift b/CircleModularWalletsCore/Sources/APIs/Paymaster/PaymasterRpcReqResp.swift new file mode 100644 index 0000000..7a3310d --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Paymaster/PaymasterRpcReqResp.swift @@ -0,0 +1,125 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +public struct GetPaymasterDataResult: Codable { + + /// Paymaster address (entrypoint v0.7) + public let paymaster: String? + + /// Paymaster data (entrypoint v0.7) + public let paymasterData: String? + + /// Paymaster post-op gas (entrypoint v0.7) + public let paymasterPostOpGasLimit: BigInt? + + /// Paymaster validation gas (entrypoint v0.7) + public let paymasterVerificationGasLimit: BigInt? + + /// Paymaster and data (entrypoint v0.6) + public let paymasterAndData: String? + + enum CodingKeys: CodingKey { + case paymaster + case paymasterData + case paymasterPostOpGasLimit + case paymasterVerificationGasLimit + case paymasterAndData + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.paymaster, forKey: .paymaster) + try container.encodeIfPresent(self.paymasterData, forKey: .paymasterData) + try container.encodeBigInt(self.paymasterPostOpGasLimit, forKey: .paymasterPostOpGasLimit) + try container.encodeBigInt(self.paymasterVerificationGasLimit, forKey: .paymasterVerificationGasLimit) + try container.encodeIfPresent(self.paymasterAndData, forKey: .paymasterAndData) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.paymaster = try container.decodeIfPresent(String.self, forKey: .paymaster) + self.paymasterData = try container.decodeIfPresent(String.self, forKey: .paymasterData) + self.paymasterPostOpGasLimit = try container.decodeToBigInt(forKey: .paymasterPostOpGasLimit) + self.paymasterVerificationGasLimit = try container.decodeToBigInt(forKey: .paymasterVerificationGasLimit) + self.paymasterAndData = try container.decodeIfPresent(String.self, forKey: .paymasterAndData) + } +} + +public struct GetPaymasterStubDataResult: Codable { + + /// Paymaster address (entrypoint v0.7) + public let paymaster: String? + + /// Paymaster data (entrypoint v0.7) + public let paymasterData: String? + + /// Paymaster post-op gas (entrypoint v0.7) + public let paymasterPostOpGasLimit: BigInt? + + /// Paymaster validation gas (entrypoint v0.7) + public let paymasterVerificationGasLimit: BigInt? + + /// Paymaster and data (entrypoint v0.6) + public let paymasterAndData: String? + + /// Indicates that the caller does not need to call pm_getPaymasterData + public let isFinal: Bool? + + /// Sponsor info + public let sponsor: SponsorInfo? + + public struct SponsorInfo: Codable { + public let name: String + public let icon: String? + } + + enum CodingKeys: CodingKey { + case paymaster + case paymasterData + case paymasterPostOpGasLimit + case paymasterVerificationGasLimit + case paymasterAndData + case isFinal + case sponsor + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.paymaster, forKey: .paymaster) + try container.encodeIfPresent(self.paymasterData, forKey: .paymasterData) + try container.encodeBigInt(self.paymasterPostOpGasLimit, forKey: .paymasterPostOpGasLimit) + try container.encodeBigInt(self.paymasterVerificationGasLimit, forKey: .paymasterVerificationGasLimit) + try container.encodeIfPresent(self.paymasterAndData, forKey: .paymasterAndData) + try container.encodeIfPresent(self.isFinal, forKey: .isFinal) + try container.encodeIfPresent(self.sponsor, forKey: .sponsor) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.paymaster = try container.decodeIfPresent(String.self, forKey: .paymaster) + self.paymasterData = try container.decodeIfPresent(String.self, forKey: .paymasterData) + self.paymasterPostOpGasLimit = try container.decodeToBigInt(forKey: .paymasterPostOpGasLimit) + self.paymasterVerificationGasLimit = try container.decodeToBigInt(forKey: .paymasterVerificationGasLimit) + self.paymasterAndData = try container.decodeIfPresent(String.self, forKey: .paymasterAndData) + self.isFinal = try container.decodeIfPresent(Bool.self, forKey: .isFinal) + self.sponsor = try container.decodeIfPresent(GetPaymasterStubDataResult.SponsorInfo.self, forKey: .sponsor) + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Public/PublicRpcApi.swift b/CircleModularWalletsCore/Sources/APIs/Public/PublicRpcApi.swift new file mode 100644 index 0000000..3682542 --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Public/PublicRpcApi.swift @@ -0,0 +1,208 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt +import Web3Core + +enum FeeValuesType { + case eip1559 + case legacy +} + +protocol PublicRpcApi { + + /// Returns the balance of an address in wei. + func getBalance(transport: Transport, + address: String, + blockNumber: BlockNumber) async throws -> BigInt + + /// Returns the number of the most recent block seen. + func getBlockNum(transport: Transport) async throws -> BigInt + + /// Returns information about a block at a block number (hex) or tag. + func getBlock(transport: Transport, + includeTransactions: Bool, + blockNumber: BlockNumber) async throws -> Block + + /// Returns the chain ID associated with the current network. + func _getChainId(transport: Transport) async throws -> String + + /// Executes a new message call immediately without submitting a transaction to the network. + func ethCall(transport: Transport, + transaction: CodableTransaction, + blockNumber: BlockNumber) async throws -> String + + /// Retrieves the bytecode at an address. + func getCode(transport: Transport, + address: String, + blockNumber: BlockNumber) async throws -> String + + /// - Parameter transport: Estimate fee per gas for EIP-1159 + /// - Returns: EstimateFeesPerGasResult + func estimateFeesPerGas(transport: Transport, feeValuesType: FeeValuesType) async throws -> EstimateFeesPerGasResult + + /** Returns the current price of gas (in wei) */ + func getGasPrice(transport: Transport) async throws -> BigInt +} + +extension PublicRpcApi { + + func getBalance(transport: Transport, + address: String, + blockNumber: BlockNumber = .latest) async throws -> BigInt { + let params = [address, blockNumber.description] + let req = RpcRequest(method: "eth_getBalance", params: params) + let response = try await transport.request(req) as RpcResponse + + guard let bigInt = HexUtils.hexToBigInt(hex: response.result) else { + throw BaseError(shortMessage: "Failed to transform to BigInt") + } + + return bigInt + } + + func getBlockNum(transport: Transport) async throws -> BigInt { + let req = RpcRequest(method: "eth_blockNumber", params: emptyParams) + let response = try await transport.request(req) as RpcResponse + + guard let bigInt = HexUtils.hexToBigInt(hex: response.result) else { + throw BaseError(shortMessage: "Failed to transform to BigInt") + } + + return bigInt + } + + func getBlock(transport: Transport, + includeTransactions: Bool = false, + blockNumber: BlockNumber = .latest) async throws -> Block { + let params: [AnyEncodable] = [AnyEncodable(blockNumber.description), + AnyEncodable(includeTransactions)] + let req = RpcRequest(method: "eth_getBlockByNumber", params: params) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + func _getChainId(transport: Transport) async throws -> String { + let req = RpcRequest(method: "eth_chainId", params: emptyParams) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + func ethCall(transport: Transport, + transaction: CodableTransaction, + blockNumber: BlockNumber = .latest) async throws -> String { + let params = EthCallParams( + from: transaction.from?.address, + to: transaction.to.address, + data: HexUtils.dataToHex(transaction.data), + block: blockNumber.description + ) + let req = RpcRequest(method: "eth_call", params: params) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + func getCode(transport: Transport, + address: String, + blockNumber: BlockNumber = .latest) async throws -> String { + let params = [address, blockNumber.description] + let req = RpcRequest(method: "eth_getCode", params: params) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + func estimateFeesPerGas(transport: Transport, + feeValuesType: FeeValuesType = .eip1559) async throws -> EstimateFeesPerGasResult { + do { + let baseFeeMultiplier = 1.2 + let block = try await getBlock(transport: transport) + + switch feeValuesType { + case .eip1559: + guard let baseFeePerGas = block.baseFeePerGas else { + throw BaseError(shortMessage: "Eip1559FeesNotSupportedError") + } + let maxPriorityFeePerGas = try await estimateMaxPriorityFeePerGas(transport: transport, + block: block) + let newBaseFeePerGas = BigInt(baseFeePerGas) * BigInt(baseFeeMultiplier) + let maxFeePerGas = newBaseFeePerGas + maxPriorityFeePerGas + return EstimateFeesPerGasResult(maxFeePerGas: maxFeePerGas, + maxPriorityFeePerGas: maxPriorityFeePerGas, + gasPrice: nil) + case .legacy: + let gasPrice = try await getGasPrice(transport: transport) + return EstimateFeesPerGasResult(maxFeePerGas: nil, + maxPriorityFeePerGas: nil, + gasPrice: gasPrice) + } + + } catch let error as BaseError { + throw error + + } catch { + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } + + func getGasPrice(transport: Transport) async throws -> BigInt { + let req = RpcRequest(method: "eth_gasPrice", params: emptyParams) + let response = try await transport.request(req) as RpcResponse + + if let result = HexUtils.hexToBigInt(hex: response.result) { + return result + } else { + throw CommonError.invalidHexString + } + } +} + +extension PublicRpcApi { + + func estimateMaxPriorityFeePerGas(transport: Transport, + block: Block) async throws -> BigInt { + do { + return try await getMaxPriorityFeePerGas(transport: transport) + } catch { + return try await estimateMaxPriorityFeePerGasFallback(transport: transport, + block: block) + } + } + + func getMaxPriorityFeePerGas(transport: Transport) async throws -> BigInt { + let req = RpcRequest(method: "eth_maxPriorityFeePerGas", params: emptyParams) + let response = try await transport.request(req) as RpcResponse + + if let result = HexUtils.hexToBigInt(hex: response.result) { + return result + } else { + throw CommonError.invalidHexString + } + } + + func estimateMaxPriorityFeePerGasFallback(transport: Transport, + block: Block) async throws -> BigInt { + guard let baseFeePerGas = block.baseFeePerGas else { + throw BaseError(shortMessage: "Eip1559FeesNotSupportedError") + } + let gasPrice = try await getGasPrice(transport: transport) + let maxPriorityFeePerGas = gasPrice - BigInt(baseFeePerGas) + return max(maxPriorityFeePerGas, .zero) + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Public/PublicRpcReqResp.swift b/CircleModularWalletsCore/Sources/APIs/Public/PublicRpcReqResp.swift new file mode 100644 index 0000000..861b170 --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Public/PublicRpcReqResp.swift @@ -0,0 +1,71 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +struct EthCallParams: Encodable { + let from: String? + let to: String + let data: String + let block: String + + enum TransactionCodingKeys: String, CodingKey { + case from + case to + case data + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + var nested = container.nestedContainer(keyedBy: TransactionCodingKeys.self) + if let from = from { + try nested.encode(from, forKey: .from) + } + try nested.encode(to, forKey: .to) + try nested.encode(data, forKey: .data) + try container.encode(block) + } +} + +/// Result for ``PublicRpcApi/estimateFeesPerGas(transport:feeValuesType:)`` +public struct EstimateFeesPerGasResult: Encodable { + + let maxFeePerGas: BigInt? // eip1559 + let maxPriorityFeePerGas: BigInt? // eip1559 + let gasPrice: BigInt? // legacy + + init(maxFeePerGas: BigInt?, maxPriorityFeePerGas: BigInt?, gasPrice: BigInt? = nil) { + self.maxFeePerGas = maxFeePerGas + self.maxPriorityFeePerGas = maxPriorityFeePerGas + self.gasPrice = gasPrice + } + + enum CodingKeys: CodingKey { + case maxFeePerGas + case maxPriorityFeePerGas + case gasPrice + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeBigInt(self.maxFeePerGas, forKey: .maxFeePerGas) + try container.encodeBigInt(self.maxPriorityFeePerGas, forKey: .maxPriorityFeePerGas) + try container.encodeBigInt(self.gasPrice, forKey: .gasPrice) + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Rp/RpRpcApi.swift b/CircleModularWalletsCore/Sources/APIs/Rp/RpRpcApi.swift new file mode 100644 index 0000000..346b552 --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Rp/RpRpcApi.swift @@ -0,0 +1,79 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol RpRpcApi { + + static func getRegistrationOptions( + transport: Transport, + userName: String + ) async throws -> PublicKeyCredentialCreationOptions + + static func getRegistrationVerification( + transport: Transport, + registrationCredential: RegistrationCredential + ) async throws -> GetRegistrationVerificationResult + + static func getLoginOptions( + transport: Transport + ) async throws -> PublicKeyCredentialRequestOptions + + static func getLoginVerification( + transport: Transport, + authenticationCredential: AuthenticationCredential + ) async throws -> GetLoginVerificationResult +} + +extension RpRpcApi { + + static func getRegistrationOptions( + transport: Transport, + userName: String + ) async throws -> PublicKeyCredentialCreationOptions { + let req = RpcRequest(method: "rp_getRegistrationOptions", params: [userName]) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + static func getRegistrationVerification( + transport: Transport, + registrationCredential: RegistrationCredential + ) async throws -> GetRegistrationVerificationResult { + let req = RpcRequest(method: "rp_getRegistrationVerification", params: [registrationCredential]) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + static func getLoginOptions( + transport: Transport + ) async throws -> PublicKeyCredentialRequestOptions { + let req = RpcRequest(method: "rp_getLoginOptions", params: emptyParams) + let response = try await transport.request(req) as RpcResponse + return response.result + } + + static func getLoginVerification( + transport: Transport, + authenticationCredential: AuthenticationCredential + ) async throws -> GetLoginVerificationResult { + let req = RpcRequest(method: "rp_getLoginVerification", params: [authenticationCredential]) + let response = try await transport.request(req) as RpcResponse + return response.result + } +} diff --git a/CircleModularWalletsCore/Sources/APIs/Rp/RpRpcReqResp.swift b/CircleModularWalletsCore/Sources/APIs/Rp/RpRpcReqResp.swift new file mode 100644 index 0000000..f27a63a --- /dev/null +++ b/CircleModularWalletsCore/Sources/APIs/Rp/RpRpcReqResp.swift @@ -0,0 +1,34 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol EmptyResponse: Codable {} + +typealias GetRegistrationOptionsResult = PublicKeyCredentialCreationOptions + +struct GetRegistrationVerificationResult: EmptyResponse { +} + +typealias GetLoginOptionsResult = PublicKeyCredentialRequestOptions + +struct GetLoginVerificationResult: Codable { + + // Passkey public key, Base64URL encoded string + let publicKey: String +} diff --git a/CircleModularWalletsCore/Sources/Accounts/Account.swift b/CircleModularWalletsCore/Sources/Accounts/Account.swift new file mode 100644 index 0000000..a05f654 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Accounts/Account.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol Account { + associatedtype T: Decodable + func getAddress() -> String + func sign(hex: String) async throws -> T + func signMessage(message: String) async throws -> T + func signTypedData(jsonData: String) async throws -> T +} diff --git a/CircleModularWalletsCore/Sources/Accounts/CirclePasskeyAccount.swift b/CircleModularWalletsCore/Sources/Accounts/CirclePasskeyAccount.swift new file mode 100644 index 0000000..e996078 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Accounts/CirclePasskeyAccount.swift @@ -0,0 +1,504 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt +import Web3Core +import web3swift + +public func toCircleSmartAccount(client: Client, + owner: A, + version: String = "") async throws -> CircleSmartAccount where A.T == SignResult { + try await .init(client: client, owner: owner) +} + +public class CircleSmartAccount: SmartAccount where A.T == SignResult { + public let client: Client + public let entryPoint: EntryPoint + let owner: A + let wallet: Wallet + + init(client: Client, owner: A, wallet: Wallet, entryPoint: EntryPoint = .v07) { + self.client = client + self.owner = owner + self.wallet = wallet + self.entryPoint = entryPoint + } + + convenience init(client: Client, owner: A) async throws { + guard let buidlTransport = client.transport as? ModularTransport else { + throw BaseError(shortMessage: "The property client.transport is not the ModularTransport") + } + guard let webAuthnAccount = owner as? WebAuthnAccount else { + throw BaseError(shortMessage: "The property owner is not the WebAuthnAccount") + } + + let (publicKeyX, publicKeyY) = Self.extractXYFromCOSE(webAuthnAccount.credential.publicKey) + let request = CreateWalletRequest( + scaConfiguration: ScaConfiguration( + initialOwnershipConfiguration: .init( + ownershipContractAddress: nil, + weightedMultiSig: .init( + owners: nil, + webauthnOwners: [.init(publicKeyX: publicKeyX.description, + publicKeyY: publicKeyY.description, + weight: PUBLIC_KEY_OWN_WEIGHT)], + thresholdWeight: THRESHOLD_WEIGHT) + ), + scaCore: "circle_6900_v1", + initCode: nil) + ) + + let wallet = try await buidlTransport.circleGetAddress(transport: buidlTransport, req: request) + + self.init(client: client, owner: owner, wallet: wallet) + } + + public var userOperation: UserOperationConfiguration? { + get async { + let minimumVerificationGasLimit = await self.isDeployed() ? + MINIMUM_VERIFICATION_GAS_LIMIT : MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT + + let config = UserOperationConfiguration { userOperation in + let verificationGasLimit = BigInt(minimumVerificationGasLimit) + let maxGasLimit = max(verificationGasLimit, userOperation.verificationGasLimit ?? BigInt(0)) + + return EstimateUserOperationGasResult(preVerificationGas: nil, + verificationGasLimit: maxGasLimit, + callGasLimit: nil, + paymasterVerificationGasLimit: nil, + paymasterPostOpGasLimit: nil) + } + + return config + } + } + + static func create(url: String, apiKey: String, client: Client, owner: A) -> CircleSmartAccount { + let account = CircleSmartAccount(client: client, owner: owner, wallet: Wallet()) + return account + } + + public func getAddress() -> String { + return wallet.address ?? "" + } + + public func encodeCalls(args: [EncodeCallDataArg]) -> String? { + return Utils.encodeCallData(args: args) + } + + public func getFactoryArgs() async throws -> (String, String)? { + if await isDeployed() { + return nil + } + + guard let initCode = wallet.getInitCode() else { + throw BaseError(shortMessage: "There is no the initCode (factory address and data)") + } + + return Utils.parseFactoryAddressAndData(initCode: initCode) + } + + public func getNonce(key: BigInt?) async throws -> BigInt { + return try await Utils.getNonce(transport: client.transport, + address: getAddress(), + entryPoint: entryPoint) + } + + public func getStubSignature(userOp: T) -> String { + return STUB_SIGNATURE + } + + public func sign(hex: String) async throws -> String { + let digest = Utils.toSha3Data(message: hex) + let hash = getReplaySafeHash( + chainId: client.chain.chainId, + account: getAddress(), + hash: HexUtils.dataToHex(digest) + ) + + do { + let signResult = try await owner.sign(hex: hash) + let signature = encodePackedForSignature( + signResult: signResult, + publicKey: owner.getAddress(), + hasUserOpGas: false + ) + return signature + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: "CircleSmartAccount.owner.sign(hex: \"\(hash)\") failure", + args: .init(cause: error, name: String(describing: error))) + } + } + + public func signMessage(message: String) async throws -> String { + guard let messageHash = Utilities.hashPersonalMessage(Data(message.utf8)) else { + throw BaseError(shortMessage: "Failed to hash message: \"\(message)\"") + } + + let messageHashHex = HexUtils.dataToHex(messageHash) + + let digest = Utils.toSha3Data(message: messageHashHex) + let hash = getReplaySafeHash( + chainId: client.chain.chainId, + account: getAddress(), + hash: HexUtils.dataToHex(digest) + ) + + do { + let signResult = try await owner.sign(hex: hash) + let signature = encodePackedForSignature( + signResult: signResult, + publicKey: owner.getAddress(), + hasUserOpGas: false + ) + return signature + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: "CircleSmartAccount.owner.sign(hex: \"\(hash)\") failure", + args: .init(cause: error, name: String(describing: error))) + } + } + + public func signTypedData(typedData: String) async throws -> String { + guard let typedData = try? EIP712Parser.parse(typedData), + let typedDataHash = try? typedData.signHash() else { + logger.passkeyAccount.error("jsonData signHash failure") + throw BaseError(shortMessage: "Failed to hash TypedData: \"\(typedData)\"") + } + + let typedDataHashHex = HexUtils.dataToHex(typedDataHash) + + let digest = Utils.toSha3Data(message: typedDataHashHex) + let hash = getReplaySafeHash( + chainId: client.chain.chainId, + account: getAddress(), + hash: HexUtils.dataToHex(digest) + ) + + do { + let signResult = try await owner.sign(hex: hash) + let signature = encodePackedForSignature( + signResult: signResult, + publicKey: owner.getAddress(), + hasUserOpGas: false + ) + return signature + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: "CircleSmartAccount.owner.sign(hex: \"\(hash)\") failure", + args: .init(cause: error, name: String(describing: error))) + } + } + + public func signUserOperation(chainId: Int, userOp: UserOperationV07) async throws -> String { + userOp.sender = getAddress() + let userOpHash = Utils.getUserOperationHash( + chainId: chainId, + entryPointAddress: EntryPoint.v07.address, + userOp: userOp + ) + + let hash = Utils.hashMessage(hex: userOpHash) + + do { + let signResult = try await owner.sign(hex: hash) + let signature = encodePackedForSignature( + signResult: signResult, + publicKey: owner.getAddress(), + hasUserOpGas: true + ) + return signature + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: "CircleSmartAccount.owner.sign(hex: hash(\"\(userOpHash)\")) failure", + args: .init(cause: error, name: String(describing: error))) + } + } + + public func getInitCode() -> String? { + return wallet.getInitCode() + } +} + +extension CircleSmartAccount: PublicRpcApi { + + // MARK: Internal Usage + + private func isDeployed() async -> Bool { + do { + let byteCode = try await getCode(transport: client.transport, + address: getAddress()) + let isEmpty = try HexUtils.hexToBytes(hex: byteCode).isEmpty + return !isEmpty + } catch { + return false + } + } + + /// Remove the private access control for unit testing + func getReplaySafeHash( + chainId: Int, + account: String, + hash: String, + verifyingContract: String = CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN + ) -> String { + // Get the prefix + let messagePrefix = "0x1901" + let prefix = HexUtils.hexToData(hex: messagePrefix) ?? .init() + + // Get the domainSeparatorHash + let domainSeparatorTypeHash = + Utils.toSha3Data(message: "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)") + + var types: [ABI.Element.ParameterType] = [ + .bytes(length: 32), + .bytes(length: 32), + .uint(bits: 256), + .address, + .bytes(length: 32) + ] + var values: [Any] = [ + domainSeparatorTypeHash, + Self.getModuleIdHash(), + chainId, + verifyingContract, + Utils.pad(data: Utils.toData(value: account), isRight: true) + ] + + var domainSeparator = Data() + if let encoded = ABIEncoder.encode(types: types, values: values) { + domainSeparator = encoded + } + let domainSeparatorHash = domainSeparator.sha3(.keccak256) + + // Get the structHash + guard let bytes = try? HexUtils.hexToBytes(hex: hash) else { + logger.passkeyAccount.error("Failed to decode the hash of getReplaySafeHash into UInt8 array.") + return "" + } + + types = [.bytes(length: 32), .bytes(length: 32)] + values = [Self.getModuleTypeHash(), bytes] + var structData = Data() + if let encoded = ABIEncoder.encode(types: types, values: values) { + structData = encoded + } + let structHash = structData.sha3(.keccak256) + + // Concat the prefix, domainSeparatorHash and domainSeparatorHash + let replaySafeHash = (prefix + domainSeparatorHash + structHash).sha3(.keccak256) + + return HexUtils.dataToHex(replaySafeHash) + } + + /// Remove the private access control for unit testing + func encodePackedForSignature( + signResult: SignResult, + publicKey: String, + hasUserOpGas: Bool + ) -> String { + let pubKey = Self.extractXYFromCOSE(publicKey) + let sender = Self.getSender(x: pubKey.0, y: pubKey.1) + + let formattedSender = Self.getFormattedSender(sender: sender) + let sigType: UInt8 = hasUserOpGas ? 34 : 2 + let sigBytes = encodeWebAuthnSigDynamicPart(signResult: signResult) + + let encoded = Utils.encodePacked([ + formattedSender, + /// dynamicPos + /// 32-bytes public key onchain id + /// 32-bytes webauth, signature and public key position + /// 1-byte signature type + /// https://github.com/circlefin/buidl-wallet-contracts/blob/7388395fac2ac8bcd19af9a1caaac5df3c4813f2/docs/Smart_Contract_Signatures_Encoding.md#sigtype--2 + 65, + sigType, + sigBytes.count, + sigBytes + ]).addHexPrefix() + + return encoded + } + + private func encodeWebAuthnSigDynamicPart(signResult: SignResult) -> Data { + guard let (rData, sData) = Utils.extractRSFromDER(signResult.signature) else { + logger.passkeyAccount.notice("Can't extract the r, s from a DER-encoded ECDSA signature") + return .init() + } + + let (r, s) = (BigUInt(rData), BigUInt(sData)) + + let encoded = Self.encodeParametersWebAuthnSigDynamicPart( + authenticatorDataString: signResult.webAuthn.authenticatorData, + clientDataJSON: signResult.webAuthn.clientDataJSON, + challengeIndex: signResult.webAuthn.challengeIndex, + typeIndex: signResult.webAuthn.typeIndex, + userVerificationRequired: signResult.webAuthn.userVerificationRequired, + r: r, + s: s + ) + + return encoded + } + + static func getModuleIdHash() -> Data { + let message = Utils.encodePacked(["Weighted Multisig Webauthn Plugin", "1.0.0"]) + + return Utils.toSha3Data(message:message) + } + + static func getModuleTypeHash() -> Data { + return Utils.toSha3Data(message: "CircleWeightedWebauthnMultisigMessage(bytes32 hash)") + } + + static func encodeParametersWebAuthnSigDynamicPart( + authenticatorDataString: String, // Hex + clientDataJSON: String, // Base64URL decoded + challengeIndex: Int, + typeIndex:Int, + userVerificationRequired: Bool, + r: BigUInt, + s: BigUInt + ) -> Data { + let types: [ABI.Element.ParameterType] = [ + .tuple(types: [ + .tuple(types: [ + .dynamicBytes, + .string, + .uint(bits: 256), + .uint(bits: 256), + .bool]), + .uint(bits: 256), + .uint(bits: 256) + ]) + ] + + var authenticatorData = [UInt8]() + if let _authenticatorData = try? HexUtils.hexToBytes(hex: authenticatorDataString) { + authenticatorData = _authenticatorData + } + + let values: [Any] = [ + [ + [ + authenticatorData, + clientDataJSON, + BigUInt(challengeIndex), + BigUInt(typeIndex), + userVerificationRequired + ], + r, + s + ] + ] + + var encoded = Data() + if let _encoded = ABIEncoder.encode(types: types, values: values) { + encoded = _encoded + } + + return encoded + } + + private static func extractXYFromCOSE(_ keyHex: String) -> (BigUInt, BigUInt) { + let xy = Self.extractXYFromCOSEBytes(keyHex) + return (BigUInt(Data(xy.0)), BigUInt(Data(xy.1))) + } + + private static func extractXYFromCOSEBytes(_ keyHex: String) -> ([UInt8], [UInt8]) { + + guard let bytes = try? HexUtils.hexToBytes(hex: keyHex) else { + logger.passkeyAccount.error("Failed to decode the publicKey (COSE_Key format) hex string into UInt8 array.") + return (.init(), .init()) + } + + // EC2 key type + guard bytes.count == 77 else { + logger.passkeyAccount.error("Insufficient bytes length; does not comply with COSE Key format EC2 key type.") + return (.init(), .init()) + } + + let offset = 10 + + let x = Array(bytes[offset..<(offset + 32)]) + let y = Array(bytes[(offset + 32 + 3)..<(offset + 64 + 3)]) + + return (x, y) + } + + static func getSender(x: BigUInt, y: BigUInt) -> String { + let encoded = encodeParametersGetSender(x, y) + let hash = encoded.sha3(.keccak256) + return HexUtils.dataToHex(hash) + } + + static func encodeParametersGetSender(_ x: BigUInt, _ y: BigUInt) -> Data { + let types: [ABI.Element.ParameterType] = [ + .uint(bits: 256), + .uint(bits: 256) + ] + let values = [x, y] + + let encoded = ABIEncoder.encode(types: types, values: values) ?? Data() + + return encoded + } + + static func getFormattedSender(sender: String) -> [UInt8] { + func slice(value: String, + start: Int? = nil, + end: Int? = nil, + strict: Bool = false) -> String { + let cleanValue = value.noHexPrefix + let startIndex = (start ?? 0) * 2 + let endIndex = (end ?? (cleanValue.count / 2)) * 2 + + guard startIndex >= 0, endIndex <= cleanValue.count, startIndex <= endIndex else { + logger.passkeyAccount.notice("Return \"0x\" if indices are invalid") + return "0x" + } + + let slicedValue = "0x" + cleanValue[startIndex.. String + func encodeCalls(args: [EncodeCallDataArg]) -> String? + func getFactoryArgs() async throws -> (String, String)? + func getNonce(key: BigInt?) async throws -> BigInt + func getStubSignature(userOp: T) -> String + func sign(hex: String) async throws -> String + func signMessage(message: String) async throws -> String + func signTypedData(typedData: String) async throws -> String + func signUserOperation(chainId: Int, userOp: UserOperationV07) async throws -> String + func getInitCode() async -> String? +} + +public struct UserOperationConfiguration { + var estimateGas: ((UserOperation) async -> EstimateUserOperationGasResult?)? +} diff --git a/CircleModularWalletsCore/Sources/Accounts/WebAuthnAccount.swift b/CircleModularWalletsCore/Sources/Accounts/WebAuthnAccount.swift new file mode 100644 index 0000000..26cef5a --- /dev/null +++ b/CircleModularWalletsCore/Sources/Accounts/WebAuthnAccount.swift @@ -0,0 +1,116 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Web3Core +import web3swift +import BigInt + +public func toWebAuthnAccount(_ credential: WebAuthnCredential) -> WebAuthnAccount { + return .init(credential: credential) +} + +public struct WebAuthnAccount: Account { + + let credential: WebAuthnCredential + + public func getAddress() -> String { + return credential.publicKey + } + + public func sign(hex: String) async throws -> SignResult { + do { + /// Step 1. Get RequestOptions + let option = try WebAuthnUtils.getRequestOption( + rpId: credential.rpId, + allowCredentialId: credential.id, + hex: hex) + + /// Step 2. get credential */ + let credential = try await WebAuthnHandler.shared.signInWith(option: option) + guard let authCredential = credential as? AuthenticationCredential, + let response = authCredential.response as? AuthenticatorAssertionResponse else { + let error = WebAuthnCredentialError.authenticationCredentialCastingFailed + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + + let userVerification = option.userVerification?.rawValue ?? "" + guard let webAuthnData = authCredential.toWebAuthnData(userVerification: userVerification) else { + throw BaseError(shortMessage: "Failed toWebAuthnData() from the AuthenticationCredential") + } + + let signatureHex = HexUtils.bytesToHex(response.signature.decodedBytes) + guard let (rData, sData) = Utils.extractRSFromDER(signatureHex) else { + throw BaseError(shortMessage: "Can't extract the r, s from a DER-encoded ECDSA signature") + } + + let adjustedSignature = WebAuthnAccount.adjustSignature((r: rData, s: sData)) + let adjustedSignatureDerFormatHex = Utils.packRSIntoDer(adjustedSignature) + + let signResult = SignResult( + signature: adjustedSignatureDerFormatHex, + webAuthn: webAuthnData, + raw: authCredential) + + return signResult + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } + + public func signMessage(message: String) async throws -> SignResult { + guard let hash = Utilities.hashPersonalMessage(Data(message.utf8)) else { + throw BaseError(shortMessage: "Failed to hash message: \"\(message)\"") + } + + let hex = HexUtils.dataToHex(hash) + return try await sign(hex: hex) + } + + public func signTypedData(jsonData: String) async throws -> SignResult { + guard let typedData = try? EIP712Parser.parse(jsonData), + let hash = try? typedData.signHash() else { + throw BaseError(shortMessage: "Failed to hash TypedData: \"\(jsonData)\"") + } + + let hex = HexUtils.dataToHex(hash) + return try await sign(hex: hex) + } +} + +extension WebAuthnAccount { + + // MARK: Internal Usage + + static func adjustSignature(_ signature: (r: Data, s: Data)) -> (Data, Data) { + let P256_N = BigUInt("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", radix: 16)! + let P256_N_DIV_2 = P256_N >> 1 + let sBigUInt = BigUInt(signature.s) + + if sBigUInt > P256_N_DIV_2 { + return (signature.r, (P256_N - sBigUInt).serialize()) + } else { + return (signature.r, signature.s) + } + } + +} diff --git a/CircleModularWalletsCore/Sources/Accounts/WebAuthnCredential.swift b/CircleModularWalletsCore/Sources/Accounts/WebAuthnCredential.swift new file mode 100644 index 0000000..0eff9b2 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Accounts/WebAuthnCredential.swift @@ -0,0 +1,148 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import AuthenticationServices +import Web3Core + +public enum WebAuthnMode { + case register + case login +} + +enum WebAuthnCredentialError: Error { + case register + case registerUnknownAuthType + case requestUnknownAuthType + case registrationCredentialCastingFailed + case authenticationCredentialCastingFailed + case getPublicKeyFailed +} + +public func toWebAuthnCredential( + transport: Transport, + userName: String? = nil, + mode: WebAuthnMode +) async throws -> WebAuthnCredential { + switch mode { + case .register: + guard let userName else { + let error = WebAuthnCredentialError.register + throw BaseError(shortMessage: "The userName cannot be nil", + args: .init(cause: error, name: String(describing: error))) + } + return try await WebAuthnCredential.register(transport: transport, userName: userName) + + case .login: + return try await WebAuthnCredential.login(transport: transport) + } +} + +public struct WebAuthnCredential: RpRpcApi { + + /// Credential ID property + public let id: String + + /// PublicKey property, (serialized hex) string + public let publicKey: String + + /// Web Authentication API returned PublicKeyCredential object + public let raw: PublicKeyCredential + + /// Relying party identifier + public let rpId: String + + static func register(transport: Transport, + userName: String) async throws -> WebAuthnCredential { + do { + /// Step 1. RP getRegistrationOptions + logger.webAuthn.debug("Register userName \(userName)") + let option = try await getRegistrationOptions(transport: transport, userName: userName) + + /// Step 2. Create credential + let registerCredential = try await WebAuthnHandler.shared.signUpWith(option: option) + guard let credential = registerCredential as? RegistrationCredential else { + let error = WebAuthnCredentialError.registrationCredentialCastingFailed + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + + /// Step 3. RP getRegistrationVerification + _ = try await getRegistrationVerification( + transport: transport, + registrationCredential: credential + ) + + guard let attestationResponse = credential.response as? AuthenticatorAttestationResponse, + let publicKey = attestationResponse.publicKey else { + let error = WebAuthnCredentialError.getPublicKeyFailed + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + + /// After the server verifies the registration and creates the user account, sign in the user with the new account. + /// Step 4. parse and serialized public key + let serializedPublicKey = HexUtils.bytesToHex(publicKey.decodedBytes) + return WebAuthnCredential(id: credential.id, + publicKey: serializedPublicKey, + raw: credential, + rpId: option.relyingParty.id) + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } + + static func login(transport: Transport) async throws -> WebAuthnCredential { + do { + /// Step 1. RP getLoginOptions + logger.webAuthn.debug("Login") + let option = try await getLoginOptions(transport: transport) + + /// Step 2. Get credential + let loginCredential = try await WebAuthnHandler.shared.signInWith(option: option) + guard let credential = loginCredential as? AuthenticationCredential else { + let error = WebAuthnCredentialError.authenticationCredentialCastingFailed + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + + /// Step 3. RP getLoginVerification + let loginResult = try await getLoginVerification( + transport: transport, + authenticationCredential: credential + ) + + /// After the server verifies the assertion, sign in the user. + /// Step 4. parse and serialized public key + let coseKey = try Utils.pemToCOSE(pemKey: loginResult.publicKey) + let serializedPublicKey = HexUtils.bytesToHex(coseKey) + return WebAuthnCredential(id: credential.id, + publicKey: serializedPublicKey, + raw: credential, + rpId: option.relyingParty.id) + } catch let error as BaseError { + throw error + } catch { + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } +} diff --git a/CircleModularWalletsCore/Sources/Chains/Chain.swift b/CircleModularWalletsCore/Sources/Chains/Chain.swift new file mode 100644 index 0000000..fab6632 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Chains/Chain.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol Chain { + + var chainId: Int { get } + +} diff --git a/CircleModularWalletsCore/Sources/Clients/BundlerClient.swift b/CircleModularWalletsCore/Sources/Clients/BundlerClient.swift new file mode 100644 index 0000000..92db746 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Clients/BundlerClient.swift @@ -0,0 +1,164 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Web3Core +import BigInt + +public class BundlerClient: Client, BundlerRpcApi, PublicRpcApi { + + public func estimateUserOperationGas( + account: SmartAccount, + calls: [EncodeCallDataArg], + paymaster: Paymaster? = nil, + estimateFeesPerGas: ((SmartAccount, BundlerClient, UserOperationV07) async -> EstimateFeesPerGasResult)? + ) async throws -> EstimateUserOperationGasResult { + let userOp = try await self.prepareUserOperation( + transport: transport, + account: account, + calls: calls, + partialUserOp: UserOperationV07(), + paymaster: paymaster, + bundlerClient: self, + estimateFeesPerGas: estimateFeesPerGas + ) + return try await self.estimateUserOperationGas(transport: transport, userOp: userOp, entryPoint: account.entryPoint) + } + + public func getChainId() async throws -> Int { + try await self.getChainId(transport: transport) + } + + public func getSupportedEntryPoints() async throws -> [String] { + try await self.getSupportedEntryPoints(transport: transport) + } + + public func getUserOperation(userOpHash: String) async throws -> GetUserOperationResult { + try await self.getUserOperation(transport: transport, userOpHash: userOpHash) + } + + public func getUserOperationReceipt(userOpHash: String) async throws -> GetUserOperationReceiptResult { + try await self.getUserOperationReceipt(transport: transport, userOpHash: userOpHash) + } + + public func prepareUserOperation( + account: SmartAccount, + calls: [EncodeCallDataArg]?, + partialUserOp: UserOperationV07, + paymaster: Paymaster? = nil, + estimateFeesPerGas: ((SmartAccount, BundlerClient, UserOperationV07) async -> EstimateFeesPerGasResult)? = nil + ) async throws -> UserOperationV07 { + try await self.prepareUserOperation(transport: transport, account: account, calls: calls, partialUserOp: partialUserOp, paymaster: paymaster, bundlerClient: self, estimateFeesPerGas: estimateFeesPerGas) + } + + public func sendUserOperation( + account: SmartAccount, + calls: [EncodeCallDataArg]?, + partialUserOp: UserOperationV07 = .init(), + paymaster: Paymaster? = nil, + estimateFeesPerGas: ((SmartAccount, BundlerClient, UserOperationV07) async -> EstimateFeesPerGasResult)? = nil + ) async throws -> String? { + let userOp = try await self.prepareUserOperation( + transport: transport, + account: account, + calls: calls, + partialUserOp: partialUserOp, + paymaster: paymaster, + bundlerClient: self, + estimateFeesPerGas: estimateFeesPerGas + ) + + userOp.signature = try await account.signUserOperation( + chainId: chain.chainId, + userOp: userOp + ) + + return try await self.sendUserOperation( + transport: transport, + partialUserOp: userOp, + entryPointAddress: account.entryPoint.address + ) + } + + public func waitForUserOperationReceipt( + userOpHash: String, + pollingInterval: Int = 4000, + retryCount: Int = 6, + timeout: Int? = nil + ) async throws -> GetUserOperationReceiptResult { + try await self.waitForUserOperationReceipt(transport: transport, userOpHash: userOpHash, pollingInterval: pollingInterval, retryCount: retryCount, timeout: timeout) + } + + public func getBalance( + address: String, + blockNumber: BlockNumber = .latest + ) async throws -> BigInt { + let result = try await getBalance(transport: transport, + address: address, + blockNumber: blockNumber) + return result + } + + public func getBlockNumber() async throws -> BigInt { + let result = try await getBlockNum(transport: transport) + return result + } + + public func getGasPrice() async throws-> BigInt { + let result = try await getGasPrice(transport: transport) + return result + } + + public func call(from: String?, to: String, data: Data) async throws -> String { + var fromAddress: EthereumAddress? + if let from { + fromAddress = EthereumAddress(from) + } + + guard let toAddress = EthereumAddress(to) else { + throw BaseError(shortMessage: "EthereumAddress initialization failed") + } + + var transaction = CodableTransaction(to: toAddress, data: data) + transaction.from = fromAddress + + return try await ethCall(transport: transport, transaction: transaction) + } + + public func getCode( + address: String, + blockNumber: BlockNumber = .latest + ) async throws -> String { + return try await getCode(transport: transport, + address: address, + blockNumber: blockNumber) + } + + public func estimateMaxPriorityFeePerGas() async throws -> BigInt { + return try await getMaxPriorityFeePerGas(transport: transport) + } + + public func getBlock( + includeTransactions: Bool = false, + blockNumber: BlockNumber = .latest + ) async throws -> Block { + return try await getBlock(transport: transport, + includeTransactions: includeTransactions, + blockNumber: blockNumber) + } +} diff --git a/CircleModularWalletsCore/Sources/Clients/Client.swift b/CircleModularWalletsCore/Sources/Clients/Client.swift new file mode 100644 index 0000000..0bce451 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Clients/Client.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public class Client { + + public let chain: Chain + public let transport: Transport + + public init(chain: Chain, transport: Transport) { + self.chain = chain + self.transport = transport + } +} diff --git a/CircleModularWalletsCore/Sources/Clients/PaymasterClient.swift b/CircleModularWalletsCore/Sources/Clients/PaymasterClient.swift new file mode 100644 index 0000000..27d016f --- /dev/null +++ b/CircleModularWalletsCore/Sources/Clients/PaymasterClient.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public class PaymasterClient: Client, PaymasterRpcApi { + + public func getPaymasterData( + userOp: T, + entryPoint: EntryPoint, + context: [String: AnyEncodable]? = nil + ) async throws -> GetPaymasterDataResult { + try await self.getPaymasterData(transport: transport, userOp: userOp, entryPoint: entryPoint, chainId: chain.chainId, context: context) + } + + public func getPaymasterStubData( + userOp: T, + entryPoint: EntryPoint, + context: [String: AnyEncodable]? = nil + ) async throws -> GetPaymasterStubDataResult { + try await self.getPaymasterStubData(transport: transport, userOp: userOp, entryPoint: entryPoint, chainId: chain.chainId, context: context) + } + +} diff --git a/CircleModularWalletsCore/Sources/Errors/BaseError.swift b/CircleModularWalletsCore/Sources/Errors/BaseError.swift new file mode 100644 index 0000000..9c95df7 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/BaseError.swift @@ -0,0 +1,96 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct BaseErrorParameters { + var cause: Error? = nil + var details: String? = nil + var metaMessages: [String]? = nil + var name: String = "BaseError" +} + +public class BaseError: Error, CustomStringConvertible, @unchecked Sendable { + + public let shortMessage: String + public let details: String? + public let metaMessages: [String]? + public let name: String + + public private(set) var description: String = "" + public let cause: Error? + + init(shortMessage: String, args: BaseErrorParameters = BaseErrorParameters()) { + self.shortMessage = shortMessage + self.details = BaseError.getDetails(args: args) + self.metaMessages = args.metaMessages + self.name = args.name + + self.description = BaseError.buildMessage(shortMessage: shortMessage, args: args) + self.cause = args.cause + } + + static func getDetails(args: BaseErrorParameters) -> String? { + if args.cause == nil { + return args.details + } else if let cause = args.cause as? BaseError { + return cause.details + } else { + return args.cause?.localizedDescription + } + } + + static func buildMessage(shortMessage: String, args: BaseErrorParameters) -> String { + var messageParts: [String] = [] + messageParts.append("\(args.name): \(shortMessage.isEmpty ? "An error occurred." : shortMessage)") + + if !messageParts.last!.hasSuffix("\n") { + messageParts[messageParts.count - 1] += "\n" + } + + if let metaMessages = args.metaMessages { + messageParts.append(contentsOf: metaMessages) + } + + if !messageParts.last!.hasSuffix("\n") { + messageParts[messageParts.count - 1] += "\n" + } + + if let details = getDetails(args: args) { + messageParts.append("Details: \(details)") + } + + messageParts.append("Version: \(Bundle.SDK.version)") + + return messageParts.joined(separator: "\n") + } + + public func walk(fn: ((Error?) -> Bool)? = nil) -> Error? { + return BaseError.walk(err: self, fn: fn) + } + + static func walk(err: Error? = nil, fn: ((Error?) -> Bool)? = nil) -> Error? { + if let fn = fn, fn(err) { + return err + } + if let cause = (err as? BaseError)?.cause { + return walk(err: cause, fn: fn) + } + return fn == nil ? err : nil + } +} diff --git a/CircleModularWalletsCore/Sources/Errors/BundlerErrors.swift b/CircleModularWalletsCore/Sources/Errors/BundlerErrors.swift new file mode 100644 index 0000000..950d827 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/BundlerErrors.swift @@ -0,0 +1,587 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +class AccountNotDeployedError: BaseError { + static let message = "aa20" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Smart Account is not deployed.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- No `factory`/`factoryData` or `initCode` properties are provided for Smart Account deployment.", + "- An incorrect `sender` address is provided." + ], + name: "AccountNotDeployedError" + )) + } +} + +class ExecutionRevertedError: BaseError { + static let code: Int = -32521 + + init(cause: BaseError? = nil, message: String? = nil) { + super.init(shortMessage: ExecutionRevertedError.getMessage(message), + args: BaseErrorParameters( + cause: cause, + name: "ExecutionRevertedError" + )) + } + + static func getMessage(_ message: String? = nil) -> String { + let reason = message? + .replacingOccurrences(of: "execution reverted: ", with: "") + .replacingOccurrences(of: "execution reverted", with: "") + + if let reason, !reason.isEmpty { + return "Execution reverted with reason: \(reason)." + } else { + return "Execution reverted for an unknown reason." + } + } +} + +class FailedToSendToBeneficiaryError: BaseError { + static let message = "aa91" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Failed to send funds to beneficiary.", + args: BaseErrorParameters( + cause: cause, + name: "FailedToSendToBeneficiaryError" + )) + } +} + +class GasValuesOverflowError: BaseError { + static let message = "aa94" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Gas value overflowed.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- one of the gas values exceeded 2**120 (uint120)" + ], + name: "GasValuesOverflowError" + )) + } +} + +class HandleOpsOutOfGasError: BaseError { + static let message = "aa95" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "The `handleOps` function was called by the Bundler with a gas limit too low.", + args: BaseErrorParameters( + cause: cause, + name: "HandleOpsOutOfGasError" + )) + } +} + +class InitCodeFailedError: BaseError { + static let message = "aa13" + + init(cause: BaseError? = nil, factory: String? = nil, factoryData: String? = nil, initCode: String? = nil) { + var metaMessages = [ + "This could arise when:", + "- Invalid `factory`/`factoryData` or `initCode` properties are present", + "- Smart Account deployment execution ran out of gas (low `verificationGasLimit` value)", + "- Smart Account deployment execution reverted with an error" + ] + + if let factory = factory { + metaMessages.append("factory: \(factory)") + } + if let factoryData = factoryData { + metaMessages.append("factoryData: \(factoryData)") + } + if let initCode = initCode { + metaMessages.append("initCode: \(initCode)") + } + + super.init(shortMessage: "Failed to simulate deployment for Smart Account.", + args: BaseErrorParameters( + cause: cause, + metaMessages: metaMessages, + name: "InitCodeFailedError" + )) + } +} + +class InitCodeMustCreateSenderError: BaseError { + static let message = "aa15" + + init(cause: BaseError? = nil, factory: String? = nil, factoryData: String? = nil, initCode: String? = nil) { + var metaMessages = [ + "This could arise when:", + "- `factory`/`factoryData` or `initCode` properties are invalid", + "- Smart Account initialization implementation is incorrect\n" + ] + + if let factory = factory { + metaMessages.append("factory: \(factory)") + } + if let factoryData = factoryData { + metaMessages.append("factoryData: \(factoryData)") + } + if let initCode = initCode { + metaMessages.append("initCode: \(initCode)") + } + + super.init(shortMessage: "Smart Account initialization implementation did not create an account.", + args: BaseErrorParameters( + cause: cause, + metaMessages: metaMessages, + name: "InitCodeMustCreateSenderError" + )) + } +} + +class InitCodeMustReturnSenderError: BaseError { + static let message = "aa14" + + init(cause: BaseError? = nil, factory: String? = nil, factoryData: String? = nil, initCode: String? = nil, sender: String? = nil) { + var metaMessages = [ + "This could arise when:", + "Smart Account initialization implementation does not return a sender address\n" + ] + + if let factory = factory { + metaMessages.append("factory: \(factory)") + } + if let factoryData = factoryData { + metaMessages.append("factoryData: \(factoryData)") + } + if let initCode = initCode { + metaMessages.append("initCode: \(initCode)") + } + if let sender = sender { + metaMessages.append("sender: \(sender)") + } + + super.init(shortMessage: "Smart Account initialization implementation does not return the expected sender.", + args: BaseErrorParameters( + cause: cause, + metaMessages: metaMessages, + name: "InitCodeMustReturnSenderError" + )) + } +} + +class InsufficientPrefundError: BaseError { + static let message = "aa21" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Smart Account does not have sufficient funds to execute the User Operation.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the Smart Account does not have sufficient funds to cover the required prefund, or", + "- a Paymaster was not provided." + ], + name: "InsufficientPrefundError" + )) + } +} + +class InternalCallOnlyError: BaseError { + static let message = "aa92" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Bundler attempted to call an invalid function on the EntryPoint.", + args: BaseErrorParameters( + cause: cause, + name: "InternalCallOnlyError" + )) + } +} + +class InvalidAggregatorError: BaseError { + static let message = "aa96" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Bundler used an invalid aggregator for handling aggregated User Operations.", + args: BaseErrorParameters( + cause: cause, + name: "InvalidAggregatorError" + )) + } +} + +class InvalidAccountNonceError: BaseError { + static let message = "aa25" + + init(cause: BaseError? = nil, nonce: BigInt? = nil) { + var metaMessages: [String]? = nil + if let nonce { + metaMessages = ["nonce: \(nonce)"] + } + super.init(shortMessage: "Invalid Smart Account nonce used for User Operation.", + args: BaseErrorParameters( + cause: cause, + metaMessages: metaMessages, + name: "InvalidAccountNonceError" + )) + } +} + +class InvalidBeneficiaryError: BaseError { + static let message = "aa90" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Bundler has not set a beneficiary address.", + args: BaseErrorParameters( + cause: cause, + name: "InvalidBeneficiaryError" + )) + } +} + +class InvalidFieldsError: BaseError { + static let code: Int = -32602 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Invalid fields set on User Operation.", + args: BaseErrorParameters( + cause: cause, + name: "InvalidFieldsError" + )) + } +} + +class InvalidPaymasterAndDataError: BaseError { + static let message = "aa93" + + init(cause: BaseError? = nil, paymasterAndData: String? = nil) { + var metaMessages = [ + "This could arise when:", + "- the `paymasterAndData` property is of an incorrect length\n" + ] + + if let paymasterAndData = paymasterAndData { + metaMessages.append("paymasterAndData: \(paymasterAndData)") + } + + super.init(shortMessage: "Paymaster properties provided are invalid.", + args: BaseErrorParameters( + cause: cause, + metaMessages: metaMessages, + name: "InvalidPaymasterAndDataError" + )) + } +} + +class PaymasterDepositTooLowError: BaseError { + static let code: Int = -32508 + static let message = "aa31" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Paymaster deposit for the User Operation is too low.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the Paymaster has deposited less than the expected amount via the `deposit` function" + ], + name: "PaymasterDepositTooLowError" + )) + } +} + +class PaymasterFunctionRevertedError: BaseError { + static let message = "aa33" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "The `validatePaymasterUserOp` function on the Paymaster reverted.", + args: BaseErrorParameters( + cause: cause, + name: "PaymasterFunctionRevertedError" + )) + } +} + +class PaymasterNotDeployedError: BaseError { + static let message = "aa30" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "The Paymaster contract has not been deployed.", + args: BaseErrorParameters( + cause: cause, + name: "PaymasterNotDeployedError" + )) + } +} + +class PaymasterRateLimitError: BaseError { + static let code: Int = -32504 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "UserOperation rejected because paymaster (or signature aggregator) is throttled/banned.", + args: BaseErrorParameters( + cause: cause, + name: "PaymasterRateLimitError" + )) + } +} + +class PaymasterStakeTooLowError: BaseError { + static let code: Int = -32505 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "UserOperation rejected because paymaster (or signature aggregator) is throttled/banned.", + args: BaseErrorParameters( + cause: cause, + name: "PaymasterStakeTooLowError" + )) + } +} + +class PaymasterPostOpFunctionRevertedError: BaseError { + static let message = "aa50" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Paymaster `postOp` function reverted.", + args: BaseErrorParameters( + cause: cause, + name: "PaymasterPostOpFunctionRevertedError" + )) + } +} + +class SenderAlreadyConstructedError: BaseError { + static let message = "aa10" + + init(cause: BaseError? = nil, factory: String? = nil, factoryData: String? = nil, initCode: String? = nil) { + var metaMessages = [ + "Remove the following properties and try again:" + ] + if factory != nil { + metaMessages.append("`factory`") + } + if factoryData != nil { + metaMessages.append("`factoryData`") + } + if initCode != nil { + metaMessages.append("`initCode`") + } + super.init(shortMessage: "Smart Account has already been deployed.", + args: BaseErrorParameters( + cause: cause, + metaMessages: metaMessages, + name: "SenderAlreadyConstructedError" + )) + } +} + +class SignatureCheckFailedError: BaseError { + static let code: Int = -32507 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "UserOperation rejected because account signature check failed (or paymaster signature, if the paymaster uses its data as signature).", + args: BaseErrorParameters( + cause: cause, + name: "SignatureCheckFailedError" + )) + } +} + +class SmartAccountFunctionRevertedError: BaseError { + static let message = "aa23" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "The `validateUserOp` function on the Smart Account reverted.", + args: BaseErrorParameters( + cause: cause, + name: "SmartAccountFunctionRevertedError" + )) + } +} + +class UnsupportedSignatureAggregatorError: BaseError { + static let code: Int = -32506 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "UserOperation rejected because account specified unsupported signature aggregator.", + args: BaseErrorParameters( + cause: cause, + name: "UnsupportedSignatureAggregatorError" + )) + } +} + +class UserOperationExpiredError: BaseError { + static let message = "aa22" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "User Operation expired.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the `validAfter` or `validUntil` values returned from `validateUserOp` on the Smart Account are not satisfied" + ], + name: "UserOperationExpiredError" + )) + } +} + +class UserOperationPaymasterExpiredError: BaseError { + static let message = "aa32" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Paymaster for User Operation expired.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the `validAfter` or `validUntil` values returned from `validatePaymasterUserOp` on the Paymaster are not satisfied" + ], + name: "UserOperationPaymasterExpiredError" + )) + } +} + +class UserOperationSignatureError: BaseError { + static let message = "aa24" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Signature provided for the User Operation is invalid.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the `signature` for the User Operation is incorrectly computed, and unable to be verified by the Smart Account" + ], + name: "UserOperationSignatureError" + )) + } +} + +class UserOperationPaymasterSignatureError: BaseError { + static let message = "aa34" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "Signature provided for the User Operation is invalid.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the `signature` for the User Operation is incorrectly computed, and unable to be verified by the Paymaster" + ], + name: "UserOperationPaymasterSignatureError" + )) + } +} + +class UserOperationRejectedByEntryPointError: BaseError { + static let code: Int = -32500 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "User Operation rejected by EntryPoint's `simulateValidation` during account creation or validation.", + args: BaseErrorParameters( + cause: cause, + name: "UserOperationRejectedByEntryPointError" + )) + } +} + +class UserOperationRejectedByPaymasterError: BaseError { + static let code: Int = -32501 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "User Operation rejected by Paymaster's `validatePaymasterUserOp`.", + args: BaseErrorParameters( + cause: cause, + name: "UserOperationRejectedByPaymasterError" + )) + } +} + +class UserOperationRejectedByOpCodeError: BaseError { + static let code: Int = -32502 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "User Operation rejected with op code validation error.", + args: BaseErrorParameters( + cause: cause, + name: "UserOperationRejectedByOpCodeError" + )) + } +} + +class UserOperationOutOfTimeRangeError: BaseError { + static let code: Int = -32503 + + init(cause: BaseError? = nil) { + super.init(shortMessage: "UserOperation out of time-range: either wallet or paymaster returned a time-range, and it is already expired (or will expire soon).", + args: BaseErrorParameters( + cause: cause, + name: "UserOperationOutOfTimeRangeError" + )) + } +} + +class VerificationGasLimitExceededError: BaseError { + static let message = "aa40" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "User Operation verification gas limit exceeded.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the gas used for verification exceeded the `verificationGasLimit`" + ], + name: "VerificationGasLimitExceededError" + )) + } +} + +class VerificationGasLimitTooLowError: BaseError { + static let message = "aa41" + + init(cause: BaseError? = nil) { + super.init(shortMessage: "User Operation verification gas limit is too low.", + args: BaseErrorParameters( + cause: cause, + metaMessages: [ + "This could arise when:", + "- the `verificationGasLimit` is too low to verify the User Operation" + ], + name: "VerificationGasLimitTooLowError" + )) + } +} + +class UnknownBundlerError: BaseError { + init(cause: BaseError? = nil) { + super.init(shortMessage: "An error occurred while executing user operation: \(cause?.shortMessage ?? "Unknown error")", + args: BaseErrorParameters( + cause: cause, + name: "UnknownBundlerError" + )) + } +} diff --git a/CircleModularWalletsCore/Sources/Errors/Internal/CommonError.swift b/CircleModularWalletsCore/Sources/Errors/Internal/CommonError.swift new file mode 100644 index 0000000..030d36d --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/Internal/CommonError.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum CommonError: Error { + case todo + + static let invalidHexString: BaseError = { + BaseError(shortMessage: "Invalid hex string.", args: .init(name: "invalidHexString")) + }() +} diff --git a/CircleModularWalletsCore/Sources/Errors/Internal/HttpError.swift b/CircleModularWalletsCore/Sources/Errors/Internal/HttpError.swift new file mode 100644 index 0000000..7e45221 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/Internal/HttpError.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// This enum lists possible network-related errors that might occur, making error handling more manageable. +enum HttpError: Error { + + /// Indicates an invalid URL. + case badURL + + /// Indicates a failure in the network request, storing the original error. + case requestFailed(Error) + + /// Indicates that the response received is not valid. + case invalidResponse + + /// Indicates that the data expected from the response was not found. + case dataNotFound + + /// Indicates failure in decoding the response data into the expected type. + case decodingFailed(Error) + + /// Indicates failure in encoding the request parameters. + case encodingFailed(Error) + + /// Indicates an unknown error with the associated status code. + case unknownError(statusCode: Int) + + /// Indicates an JSON-RPC error occurred when execution + case jsonrpcExecutionError(JsonRpcError) +} diff --git a/CircleModularWalletsCore/Sources/Errors/Internal/WebAuthnError.swift b/CircleModularWalletsCore/Sources/Errors/Internal/WebAuthnError.swift new file mode 100644 index 0000000..5a70166 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/Internal/WebAuthnError.swift @@ -0,0 +1,130 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An error that occured preparing or processing WebAuthn-related requests. +public struct WebAuthnError: Error, Hashable, Sendable { + enum Reason: Error { + // MARK: Shared + case attestedCredentialDataMissing + case relyingPartyIDHashDoesNotMatch + case userPresentFlagNotSet + case invalidSignature + + // MARK: AttestationObject + case userVerificationRequiredButFlagNotSet + case attestationStatementMustBeEmpty + case attestationVerificationNotSupported + + // MARK: WebAuthnManager + case invalidUserID + case unsupportedCredentialPublicKeyAlgorithm + case credentialIDAlreadyExists + case userVerifiedFlagNotSet + case potentialReplayAttack + case invalidAssertionCredentialType + + // MARK: ParsedAuthenticatorAttestationResponse + case invalidAttestationObject + case invalidAuthData + case invalidFmt + case missingAttStmt + case attestationFormatNotSupported + + // MARK: ParsedCredentialCreationResponse + case invalidCredentialCreationType + case credentialRawIDTooLong + + // MARK: AuthenticatorData + case authDataTooShort + case attestedCredentialFlagNotSet + case extensionDataMissing + case leftOverBytesInAuthenticatorData + case credentialIDTooLong + case credentialIDTooShort + case authenticatorFlagsCheckFailed + + // MARK: CredentialPublicKey + case badPublicKeyBytes + case invalidKeyType + case invalidAlgorithm + case invalidCurve + case invalidXCoordinate + case invalidYCoordinate + case unsupportedCOSEAlgorithm + case unsupportedCOSEAlgorithmForEC2PublicKey + case invalidModulus + case invalidExponent + case unsupportedCOSEAlgorithmForRSAPublicKey + case unsupported + } + + let reason: Reason + + init(reason: Reason) { + self.reason = reason + } + + // MARK: Shared + public static let attestedCredentialDataMissing = Self(reason: .attestedCredentialDataMissing) + public static let relyingPartyIDHashDoesNotMatch = Self(reason: .relyingPartyIDHashDoesNotMatch) + public static let userPresentFlagNotSet = Self(reason: .userPresentFlagNotSet) + public static let invalidSignature = Self(reason: .invalidSignature) + + // MARK: AttestationObject + public static let userVerificationRequiredButFlagNotSet = Self(reason: .userVerificationRequiredButFlagNotSet) + public static let attestationStatementMustBeEmpty = Self(reason: .attestationStatementMustBeEmpty) + public static let attestationVerificationNotSupported = Self(reason: .attestationVerificationNotSupported) + + // MARK: WebAuthnManager + public static let invalidUserID = Self(reason: .invalidUserID) + public static let unsupportedCredentialPublicKeyAlgorithm = Self(reason: .unsupportedCredentialPublicKeyAlgorithm) + public static let credentialIDAlreadyExists = Self(reason: .credentialIDAlreadyExists) + public static let userVerifiedFlagNotSet = Self(reason: .userVerifiedFlagNotSet) + public static let potentialReplayAttack = Self(reason: .potentialReplayAttack) + public static let invalidAssertionCredentialType = Self(reason: .invalidAssertionCredentialType) + + // MARK: ParsedAuthenticatorAttestationResponse + public static let invalidAttestationObject = Self(reason: .invalidAttestationObject) + public static let invalidAuthData = Self(reason: .invalidAuthData) + public static let invalidFmt = Self(reason: .invalidFmt) + public static let missingAttStmt = Self(reason: .missingAttStmt) + public static let attestationFormatNotSupported = Self(reason: .attestationFormatNotSupported) + + // MARK: ParsedCredentialCreationResponse + public static let invalidCredentialCreationType = Self(reason: .invalidCredentialCreationType) + public static let credentialRawIDTooLong = Self(reason: .credentialRawIDTooLong) + + // MARK: AuthenticatorData + public static let authDataTooShort = Self(reason: .authDataTooShort) + public static let attestedCredentialFlagNotSet = Self(reason: .attestedCredentialFlagNotSet) + public static let extensionDataMissing = Self(reason: .extensionDataMissing) + public static let leftOverBytesInAuthenticatorData = Self(reason: .leftOverBytesInAuthenticatorData) + public static let credentialIDTooLong = Self(reason: .credentialIDTooLong) + public static let credentialIDTooShort = Self(reason: .credentialIDTooShort) + public static let authenticatorFlagsCheckFailed = Self(reason: .authenticatorFlagsCheckFailed) + + // MARK: CredentialPublicKey + public static let badPublicKeyBytes = Self(reason: .badPublicKeyBytes) + public static let invalidKeyType = Self(reason: .invalidKeyType) + public static let invalidAlgorithm = Self(reason: .invalidAlgorithm) + public static let invalidCurve = Self(reason: .invalidCurve) + public static let invalidXCoordinate = Self(reason: .invalidXCoordinate) + public static let invalidYCoordinate = Self(reason: .invalidYCoordinate) + public static let unsupportedCOSEAlgorithm = Self(reason: .unsupportedCOSEAlgorithm) + public static let unsupportedCOSEAlgorithmForEC2PublicKey = Self(reason: .unsupportedCOSEAlgorithmForEC2PublicKey) + public static let invalidModulus = Self(reason: .invalidModulus) + public static let invalidExponent = Self(reason: .invalidExponent) + public static let unsupportedCOSEAlgorithmForRSAPublicKey = Self(reason: .unsupportedCOSEAlgorithmForRSAPublicKey) + public static let unsupported = Self(reason: .unsupported) +} diff --git a/CircleModularWalletsCore/Sources/Errors/RequestErrors.swift b/CircleModularWalletsCore/Sources/Errors/RequestErrors.swift new file mode 100644 index 0000000..706fbaf --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/RequestErrors.swift @@ -0,0 +1,94 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +class RpcRequestError: BaseError { + let code: Int + + init(body: Data?, error: JsonRpcError, url: String) { + self.code = error.code + super.init(shortMessage: "RPC Request failed.", + args: .init( + details: error.message, + metaMessages: ["URL: \(url)", "Request body: \(prettyPrint(body))"], + name: "RpcRequestError" + )) + } +} + +class HttpRequestError: BaseError { + let body: Data? + let headers: [String: String]? + let status: Int? + + init(body: Data? = nil, + cause: Error? = nil, + details: String? = nil, + headers: [String: String]? = nil, + status: Int? = nil, + url: String) { + self.body = body + self.headers = headers + self.status = status + super.init(shortMessage: "HTTP request failed.", + args: .init( + cause: cause, + details: details, + metaMessages: HttpRequestError.getMetaMessage(status: status, + url: url, + headers: headers, + body: body), + name: "HttpRequestError" + )) + } + + static func getMetaMessage(status: Int?, + url: String, + headers: [String: String]?, + body: Data?) -> [String] { + var result = [String]() + if let status { + result.append("Status: \(status)") + } + result.append("URL: \(url)") + if let headers { + result.append("Request headers: \(prettyPrint(headers))") + } + if let body { + result.append("Request body: \(prettyPrint(body))") + } + return result + } +} + +class TimeoutError: BaseError { + init(body: Data?, url: String) { + var metaMessages: [String] = [] + metaMessages.append("URL: \(url)") + if let body { + metaMessages.append("Request body: \(prettyPrint(body))") + } + super.init(shortMessage: "The request took too long to respond.", + args: .init( + details: "The request timed out.", + metaMessages: metaMessages, + name: "TimeoutError" + )) + } +} diff --git a/CircleModularWalletsCore/Sources/Errors/RpcErrors.swift b/CircleModularWalletsCore/Sources/Errors/RpcErrors.swift new file mode 100644 index 0000000..c7e5a54 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/RpcErrors.swift @@ -0,0 +1,284 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import Foundation + +struct RpcErrorOptions { + var code: Int? = nil + var metaMessages: [String]? = nil + var name: String? = nil + var shortMessage: String +} + +class RpcError: BaseError { + let code: Int + + init(cause: Error, options: RpcErrorOptions) { + self.code = options.code ?? -1 + super.init(shortMessage: options.shortMessage, + args: .init( + cause: cause, + metaMessages: RpcError.getMetaMessage(options: options, cause: cause), + name: RpcError.getName(options: options, cause: cause) + )) + } + + static func getMetaMessage(options: RpcErrorOptions, cause: Error) -> [String]? { + if let messages = options.metaMessages { + return messages + } else if let baseError = cause as? BaseError { + return baseError.metaMessages + } + return nil + } + + static func getName(options: RpcErrorOptions, cause: Error) -> String { + if let name = options.name { + return name + } else if let baseError = cause as? BaseError, baseError.name != "BaseError" { + return baseError.name + } + return "RpcError" + } +} + +class ParseRpcError: RpcError { + static let code = -32700 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: ParseRpcError.code, + name: "ParseRpcError", + shortMessage: "Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text." + )) + } +} + +class InvalidRequestRpcError: RpcError { + static let code = -32600 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: InvalidRequestRpcError.code, + name: "InvalidRequestRpcError", + shortMessage: "JSON is not a valid request object." + )) + } +} + +class MethodNotFoundRpcError: RpcError { + static let code = -32601 + + init(cause: Error, method: String? = nil) { + super.init(cause: cause, options: RpcErrorOptions( + code: MethodNotFoundRpcError.code, + name: "MethodNotFoundRpcError", + shortMessage: "The method\(method != nil ? " \"\(method!)\"" : "") does not exist / is not available." + )) + } +} + +class InvalidParamsRpcError: RpcError { + static let code = -32602 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: InvalidParamsRpcError.code, + name: "InvalidParamsRpcError", + shortMessage: "Invalid parameters were provided to the RPC method.\nDouble check you have provided the correct parameters." + )) + } +} + +class InternalRpcError: RpcError { + static let code = -32603 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: InternalRpcError.code, + name: "InternalRpcError", + shortMessage: "An internal error was received." + )) + } +} + +class InvalidInputRpcError: RpcError { + static let code = -32000 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: InvalidInputRpcError.code, + name: "InvalidInputRpcError", + shortMessage: "Missing or invalid parameters.\nDouble check you have provided the correct parameters." + )) + } +} + +class ResourceNotFoundRpcError: RpcError { + static let code = -32001 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: ResourceNotFoundRpcError.code, + name: "ResourceNotFoundRpcError", + shortMessage: "Requested resource not found." + )) + } +} + +class ResourceUnavailableRpcError: RpcError { + static let code = -32002 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: ResourceUnavailableRpcError.code, + name: "ResourceUnavailableRpcError", + shortMessage: "Requested resource not available." + )) + } +} + +class TransactionRejectedRpcError: RpcError { + static let code = -32003 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: TransactionRejectedRpcError.code, + name: "TransactionRejectedRpcError", + shortMessage: "Transaction creation failed." + )) + } +} + +class MethodNotSupportedRpcError: RpcError { + static let code = -32004 + + init(cause: Error, method: String? = nil) { + super.init(cause: cause, options: RpcErrorOptions( + code: MethodNotSupportedRpcError.code, + name: "MethodNotSupportedRpcError", + shortMessage: "Method\(method != nil ? " \"\(method!)\"" : "") is not implemented." + )) + } +} + +class LimitExceededRpcError: RpcError { + static let code = -32005 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: LimitExceededRpcError.code, + name: "LimitExceededRpcError", + shortMessage: "Request exceeds defined limit." + )) + } +} + +class JsonRpcVersionUnsupportedError: RpcError { + static let code = -32006 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: JsonRpcVersionUnsupportedError.code, + name: "JsonRpcVersionUnsupportedError", + shortMessage: "Version of JSON-RPC protocol is not supported." + )) + } +} + +class UnknownRpcError: ProviderRpcError { + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + name: "UnknownRpcError", + shortMessage: "An unknown RPC error occurred." + )) + } +} + +class ProviderRpcError: RpcError {} + +class UserRejectedRequestError: ProviderRpcError { + static let code = 4001 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: UserRejectedRequestError.code, + name: "UserRejectedRequestError", + shortMessage: "User rejected the request." + )) + } +} + +class UnauthorizedProviderError: ProviderRpcError { + static let code = 4100 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: UnauthorizedProviderError.code, + name: "UnauthorizedProviderError", + shortMessage: "The requested method and/or account has not been authorized by the user." + )) + } +} + +class UnsupportedProviderMethodError: ProviderRpcError { + static let code = 4200 + + init(cause: Error, method: String? = nil) { + super.init(cause: cause, options: RpcErrorOptions( + code: UnsupportedProviderMethodError.code, + name: "UnsupportedProviderMethodError", + shortMessage: "The Provider does not support the requested method\(method != nil ? " \"\(method!)\"" : "")." + )) + } +} + +class ProviderDisconnectedError: ProviderRpcError { + static let code = 4900 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: ProviderDisconnectedError.code, + name: "ProviderDisconnectedError", + shortMessage: "The Provider is disconnected from all chains." + )) + } +} + +class ChainDisconnectedError: ProviderRpcError { + static let code = 4901 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: ChainDisconnectedError.code, + name: "ChainDisconnectedError", + shortMessage: "The Provider is not connected to the requested chain." + )) + } +} + +class SwitchChainError: ProviderRpcError { + static let code = 4902 + + init(cause: Error) { + super.init(cause: cause, options: RpcErrorOptions( + code: SwitchChainError.code, + name: "SwitchChainError", + shortMessage: "An error occurred when attempting to switch chain." + )) + } +} diff --git a/CircleModularWalletsCore/Sources/Errors/UserOperationErrors.swift b/CircleModularWalletsCore/Sources/Errors/UserOperationErrors.swift new file mode 100644 index 0000000..3405c0c --- /dev/null +++ b/CircleModularWalletsCore/Sources/Errors/UserOperationErrors.swift @@ -0,0 +1,72 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// https://github.com/wevm/viem/blob/3866a6faeb9e64ba3da6063fe78a079ea53c2c5f/src/account-abstraction/errors/userOperation.ts#L104 +class WaitForUserOperationReceiptTimeoutError: BaseError { + init(hash: String) { + super.init(shortMessage: "Timed out while waiting for User Operation with hash \"\(hash)\" to be confirmed.", + args: .init(name: "WaitForUserOperationReceiptTimeoutError")) + } +} + +// https://github.com/wevm/viem/blob/3866a6faeb9e64ba3da6063fe78a079ea53c2c5f/src/account-abstraction/errors/userOperation.ts#L80 +class UserOperationReceiptNotFoundError: BaseError { + init(hash: String, cause: Error?) { + super.init(shortMessage: "User Operation receipt with hash \"\(hash)\" could not be found. The User Operation may not have been processed yet.", + args: .init(cause: cause, name: "UserOperationReceiptNotFoundError")) + } +} + +//https://github.com/wevm/viem/blob/f34580367127be8ec02e2f1a9dbf5d81c29e74e8/src/account-abstraction/errors/userOperation.ts#L89C1-L99C1 +class UserOperationNotFoundError: BaseError { + init(hash: String) { + super.init(shortMessage: "User Operation with hash \"\(hash)\" could not be found.", + args: .init(name: "UserOperationNotFoundError")) + } +} + +class UserOperationExecutionError: BaseError { + private init(cause: BaseError, parameters: BaseErrorParameters) { + super.init(shortMessage: cause.shortMessage, args: parameters) + } + + convenience init(cause: BaseError, userOp: UserOperation) { + let prettyArgs = prettyPrint(userOp) + let params = BaseErrorParameters( + cause: cause, + metaMessages: UserOperationExecutionError.getMetaMessages(cause: cause, prettyArgs: prettyArgs), + name: "UserOperationExecutionError" + ) + self.init(cause: cause, parameters: params) + } + + static func getMetaMessages(cause: BaseError, prettyArgs: String) -> [String] { + var messages: [String] = cause.metaMessages ?? [] + + if !messages.isEmpty, let last = messages.last, !(last.hasSuffix("\n")) { + messages[messages.count - 1] += "\n" + } + + messages.append("Request Arguments:") + messages.append(prettyArgs) + + return messages.filter { !$0.isEmpty } + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Constants.swift b/CircleModularWalletsCore/Sources/Helpers/Constants.swift new file mode 100644 index 0000000..1b3c94e --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Constants.swift @@ -0,0 +1,73 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public let CIRCLE_BASE_URL = "https://modular-sdk.circle.com/v1/rpc/w3s/buidl" +public let ENTRYPOINT_V07_ADDRESS = "0x0000000071727De22E5E9d8BAf0edAc6f37da032" + +let CIRCLE_DOMAIN_EIP712_Template = """ +{ + "types": { + "CircleWeightedWebauthnMultisigMessage": [ + {"name": "hash", "type": "bytes32"} + ], + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ] + }, + "primaryType": "CircleWeightedWebauthnMultisigMessage", + "domain": { + "name": "Weighted Multisig Webauthn Plugin", + "version": "1.0.0", + "chainId": $CHAINID, + "verifyingContract": "$VERIFYINGCONTRACT" + }, + "message": { + "hash": "$HASH" + } +} +""" + +public let ABI_ERC20 = "[{\"constant\":true,\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_spender\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_from\",\"type\":\"address\"},{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"name\":\"\",\"type\":\"uint8\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"version\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"name\":\"balance\",\"type\":\"uint256\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"name\":\"\",\"type\":\"string\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_to\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":false,\"inputs\":[{\"name\":\"_spender\",\"type\":\"address\"},{\"name\":\"_value\",\"type\":\"uint256\"},{\"name\":\"_extraData\",\"type\":\"bytes\"}],\"name\":\"approveAndCall\",\"outputs\":[{\"name\":\"success\",\"type\":\"bool\"}],\"payable\":false,\"type\":\"function\"},{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"},{\"name\":\"_spender\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"name\":\"remaining\",\"type\":\"uint256\"}],\"payable\":false,\"type\":\"function\"},{\"inputs\":[{\"name\":\"_initialAmount\",\"type\":\"uint256\"},{\"name\":\"_tokenName\",\"type\":\"string\"},{\"name\":\"_decimalUnits\",\"type\":\"uint8\"},{\"name\":\"_tokenSymbol\",\"type\":\"string\"}],\"type\":\"constructor\"},{\"payable\":false,\"type\":\"fallback\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_from\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"_to\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"_owner\",\"type\":\"address\"},{\"indexed\":true,\"name\":\"_spender\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},]" + +let CONTRACT_ADDRESS: [String: String] = [ + "token_1": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "token_2": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "token_3": "0xb8c77482e45f1f44de1745f52c74426c631bdd52" +] + +let STUB_SIGNATURE = "0x0000be58786f7ae825e097256fc83a4749b95189e03e9963348373e9c595b15200000000000000000000000000000000000000000000000000000000000000412200000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000006091077742edaf8be2fa866827236532ec2a5547fe2721e606ba591d1ffae7a15c022e5f8fe5614bbf65ea23ad3781910eb04a1a60fae88190001ecf46e5f5680a00000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000001700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002549960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000867b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224b6d62474d316a4d554b57794d6352414c6774553953537144384841744867486178564b6547516b503541222c226f726967696e223a22687474703a2f2f6c6f63616c686f73743a35313733222c2263726f73734f726967696e223a66616c73657d0000000000000000000000000000000000000000000000000000" + +/** The Circle Weighted WebAuthn multisig plugin address */ +let CIRCLE_WEIGHTED_WEB_AUTHN_MULTISIG_PLUGIN = "0x5a2262d58eB72B84701D6efBf6bB6586C793A65b" + +let EIP1271_VALID_SIGNATURE: [UInt8] = [0x16, 0x26, 0xba, 0x7e] +let EIP1271_INVALID_SIGNATURE: [UInt8] = [0xff, 0xff, 0xff, 0xff] + +/** The public key own weights. */ +let PUBLIC_KEY_OWN_WEIGHT = 1 + +/** The threshold weight. */ +let THRESHOLD_WEIGHT = 1 + +let MINIMUM_VERIFICATION_GAS_LIMIT = 600_000 +let MINIMUM_UNDEPLOY_VERIFICATION_GAS_LIMIT = 2_000_000 diff --git a/CircleModularWalletsCore/Sources/Helpers/Extensions/Bundle+Extension.swift b/CircleModularWalletsCore/Sources/Helpers/Extensions/Bundle+Extension.swift new file mode 100644 index 0000000..1ccc020 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Extensions/Bundle+Extension.swift @@ -0,0 +1,44 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +#if SWIFT_PACKAGE +extension Bundle { + public enum SDK { + public static let version = "0.0.2" + } +} +#else +extension Bundle { + static let SDK = Bundle(identifier: "com.circle.ModularWallets.core")! +} +#endif + +extension Bundle { + var name: String { getInfo("CFBundleName") } + var displayName: String { getInfo("CFBundleDisplayName") } + var language: String { getInfo("CFBundleDevelopmentRegion") } + var identifier: String { getInfo("CFBundleIdentifier") } + var copyright: String { getInfo("NSHumanReadableCopyright").replacingOccurrences(of: "\\\\n", with: "\n") } + + var build: String { getInfo("CFBundleVersion") } + var version: String { getInfo("CFBundleShortVersionString") } + + private func getInfo(_ str: String) -> String { infoDictionary?[str] as? String ?? "⚠️" } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Extensions/Duration+Extension.swift b/CircleModularWalletsCore/Sources/Helpers/Extensions/Duration+Extension.swift new file mode 100644 index 0000000..81c41f6 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Extensions/Duration+Extension.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +extension Duration { + /// The value of a positive duration in milliseconds, suitable to be encoded in WebAuthn types. + var milliseconds: UInt64 { + let (seconds, attoseconds) = self.components + return UInt64(seconds * 1000) + UInt64(attoseconds/1_000_000_000_000_000) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Extensions/KeyedDecodingContainer+Extension.swift b/CircleModularWalletsCore/Sources/Helpers/Extensions/KeyedDecodingContainer+Extension.swift new file mode 100644 index 0000000..408b185 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Extensions/KeyedDecodingContainer+Extension.swift @@ -0,0 +1,58 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +extension KeyedDecodingContainer { + + func decodeToBigInt(forKey key: KeyedDecodingContainer.Key) throws -> BigInt? { + let hexString = try? self.decodeIfPresent(String.self, forKey: key) + return HexUtils.hexToBigInt(hex: hexString) + } + + func decodeBytesFromURLEncodedBase64(forKey key: KeyedDecodingContainer.Key) throws -> [UInt8] { + guard let bytes = try decode( + URLEncodedBase64.self, + forKey: key + ).decodedBytes else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Failed to decode base64url encoded string at \(key) into bytes" + ) + } + return bytes + } + + func decodeBytesFromURLEncodedBase64IfPresent(forKey key: KeyedDecodingContainer.Key) throws -> [UInt8]? { + guard let bytes = try decodeIfPresent( + URLEncodedBase64.self, + forKey: key + ) else { return nil } + + guard let decodedBytes = bytes.decodedBytes else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Failed to decode base64url encoded string at \(key) into bytes" + ) + } + return decodedBytes + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Extensions/KeyedEncodingContainer+Extension.swift b/CircleModularWalletsCore/Sources/Helpers/Extensions/KeyedEncodingContainer+Extension.swift new file mode 100644 index 0000000..171418a --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Extensions/KeyedEncodingContainer+Extension.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +extension KeyedEncodingContainer { + + mutating func encodeBigInt(_ value: BigInt?, forKey key: KeyedEncodingContainer.Key) throws { + if let value { + let hexString = HexUtils.bigIntToHex(value) + try self.encodeIfPresent(hexString, forKey: key) + } + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Extensions/String+Extension.swift b/CircleModularWalletsCore/Sources/Helpers/Extensions/String+Extension.swift new file mode 100644 index 0000000..607208f --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Extensions/String+Extension.swift @@ -0,0 +1,73 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +extension String { + + var noHexPrefix: String { + let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines) + let hex = trimmed.hasPrefix("0x") ? String(trimmed.dropFirst(2)) : trimmed + return hex + } +} + +extension StringProtocol { + + func index(of string: S, options: String.CompareOptions = []) -> Index? { + range(of: string, options: options)?.lowerBound + } + + func index(of string: S, options: String.CompareOptions = []) -> Int? { + guard let endIndex = range(of: string, options: options)?.lowerBound else { + return nil + } + + return self.distance(from: self.startIndex, to: endIndex) + } + + func endIndex(of string: S, options: String.CompareOptions = []) -> Index? { + range(of: string, options: options)?.upperBound + } + + func endIndex(of string: S, options: String.CompareOptions = []) -> Int? { + guard let endIndex = range(of: string, options: options)?.upperBound else { + return nil + } + + return self.distance(from: self.startIndex, to: endIndex) + } + + func indices(of string: S, options: String.CompareOptions = []) -> [Index] { + ranges(of: string, options: options).map(\.lowerBound) + } + + func ranges(of string: S, options: String.CompareOptions = []) -> [Range] { + var result: [Range] = [] + var startIndex = self.startIndex + while startIndex < endIndex, + let range = self[startIndex...] + .range(of: string, options: options) { + result.append(range) + startIndex = range.lowerBound < range.upperBound ? range.upperBound : + index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex + } + return result + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Models/AnyEncodable.swift b/CircleModularWalletsCore/Sources/Helpers/Models/AnyEncodable.swift new file mode 100644 index 0000000..5308076 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Models/AnyEncodable.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct AnyEncodable: Encodable { + private let encodeClosure: (Encoder) throws -> Void + + init(_ value: T) { + encodeClosure = { encoder in + try value.encode(to: encoder) + } + } + + public func encode(to encoder: Encoder) throws { + try encodeClosure(encoder) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Models/Base64Utilities.swift b/CircleModularWalletsCore/Sources/Helpers/Models/Base64Utilities.swift new file mode 100644 index 0000000..0e67d4a --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Models/Base64Utilities.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// Container for base64 encoded data +public struct EncodedBase64: ExpressibleByStringLiteral, Codable, Hashable, Equatable, Sendable { + private let base64: String + + public init(_ string: String) { + self.base64 = string + } + + public init(stringLiteral value: StringLiteralType) { + self.init(value) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.base64 = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.base64) + } + + /// Return as Base64URL + public var urlEncoded: URLEncodedBase64 { + return .init( + self.base64.replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + ) + } + + /// Decodes Base64 string and transforms result into `Data` + public var decoded: Data? { + return Data(base64Encoded: self.base64) + } + + /// Returns Base64 data as a String + public func asString() -> String { + return self.base64 + } +} + +/// Container for URL encoded base64 data +public struct URLEncodedBase64: ExpressibleByStringLiteral, Codable, Hashable, Equatable, Sendable { + let base64URL: String + + /// Decodes Base64URL string and transforms result into `[UInt8]` + public var decodedBytes: [UInt8]? { + guard let base64DecodedData = urlDecoded.decoded else { return nil } + return [UInt8](base64DecodedData) + } + + public init(_ string: String) { + self.base64URL = string + } + + public init(stringLiteral value: StringLiteralType) { + self.init(value) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.base64URL = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.base64URL) + } + + /// Decodes Base64URL into Base64 + public var urlDecoded: EncodedBase64 { + var result = self.base64URL.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while result.count % 4 != 0 { + result = result.appending("=") + } + return .init(result) + } + + /// Return Base64URL as a String + public func asString() -> String { + return self.base64URL + } +} + +extension Array where Element == UInt8 { + /// Encodes an array of bytes into a base64url-encoded string + /// - Returns: A base64url-encoded string + public func base64URLEncodedString() -> URLEncodedBase64 { + let base64String = Data(bytes: self, count: self.count).base64EncodedString() + return EncodedBase64(base64String).urlEncoded + } + + /// Encodes an array of bytes into a base64 string + /// - Returns: A base64-encoded string + public func base64EncodedString() -> EncodedBase64 { + return .init(Data(bytes: self, count: self.count).base64EncodedString()) + } +} + +extension Data { + /// Encodes data into a base64url-encoded string + /// - Returns: A base64url-encoded string + public func base64URLEncodedString() -> URLEncodedBase64 { + return [UInt8](self).base64URLEncodedString() + } +} + +extension String { + func toBase64() -> EncodedBase64 { + return .init(Data(self.utf8).base64EncodedString()) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Models/ByteCasting.swift b/CircleModularWalletsCore/Sources/Helpers/Models/ByteCasting.swift new file mode 100644 index 0000000..0992682 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Models/ByteCasting.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2024 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +extension BidirectionalCollection where Element == UInt8 { + /// Cast a byte sequence into a trivial type like a primitive or a tuple of primitives. + /// + /// - Note: It is up to the caller to verify the receiver's size before casting it. + @inlinable + func casting() -> R { + precondition(self.count == MemoryLayout.size, "self.count (\(self.count)) does not match MemoryLayout.size (\(MemoryLayout.size))") + + let result = self.withContiguousStorageIfAvailable({ + $0.withUnsafeBytes { $0.loadUnaligned(as: R.self) } + }) ?? Array(self).withUnsafeBytes { + $0.loadUnaligned(as: R.self) + } + + return result + } +} + +extension FixedWidthInteger { + /// Initialize a fixed width integer from a contiguous sequence of Bytes representing a big endian type. + /// - Parameter bigEndianBytes: The Bytes to interpret as a big endian integer. + @inlinable + init(bigEndianBytes: some BidirectionalCollection) { + self.init(bigEndian: bigEndianBytes.casting()) + } + + /// Initialize a fixed width integer from a contiguous sequence of Bytes representing a little endian type. + /// - Parameter bigEndianBytes: The Bytes to interpret as a little endian integer. + @inlinable + init(littleEndianBytes: some BidirectionalCollection) { + self.init(littleEndian: littleEndianBytes.casting()) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Models/Logger.swift b/CircleModularWalletsCore/Sources/Helpers/Models/Logger.swift new file mode 100644 index 0000000..8de96a1 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Models/Logger.swift @@ -0,0 +1,71 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import OSLog + +typealias logger = Logger + +extension Logger { + + private static let appIdentifier = Bundle.main.bundleIdentifier ?? "" + + // Predefined Logger categories + // + // The categories align with the Error definitions + // https://docs.google.com/document/d/1iUQC2UFZcBkB5EFscql1XFojnF0Jir_7KjOYpCGgXEQ/edit?pli=1 + // + // - Usages: + // - logger.main.debug("some debugging messages") + // - logger.rpc.error("some error messages in RPC module") + // + // - Recommend log level use cases: + // - Debug: Useful only during debugging + // - Info: Helpful but not essential for troubleshooting + // - Default(Notice): Essential for troubleshooting + // - Error: Error seen during execution + // - Fault: Bug in program + + static let general = Logger(subsystem: appIdentifier, category: "general") + static let transport = Logger(subsystem: appIdentifier, category: "transport") + + static let bundler = Logger(subsystem: appIdentifier, category: "bundler") + static let paymaster = Logger(subsystem: appIdentifier, category: "paymaster") + static let utils = Logger(subsystem: appIdentifier, category: "utils") + static let webAuthn = Logger(subsystem: appIdentifier, category: "WebAuthn") + static let passkeyAccount = Logger(subsystem: appIdentifier, category: "passkeyAccount") +} + +extension Logger { + + func divider(level: OSLogType = .debug) { + self.log(level: level, "∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞∞") + } + + func prettyPrinted(level: OSLogType = .debug, _ object: Encodable) { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + guard let data = try? encoder.encode(object), + let jsonString = String(data: data, encoding: .utf8) else { + self.debug("Invalid JSON object") + return + } + self.log(level: level, "\(jsonString)") + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Models/PrettyPrint.swift b/CircleModularWalletsCore/Sources/Helpers/Models/PrettyPrint.swift new file mode 100644 index 0000000..48c00d2 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Models/PrettyPrint.swift @@ -0,0 +1,49 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +func prettyPrint(_ content: Any?) -> String { + + func prettyPrintDictionary(dict: [String: String]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + func prettyPrintEncodable(object: Encodable) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + guard let data = try? encoder.encode(object) else{ return nil } + return String(data: data, encoding: .utf8) + } + + if let data = content as? Data { + return String(data: data, encoding: .utf8) ?? String(describing: data) + + } else if let dict = content as? [String: String] { + return prettyPrintDictionary(dict: dict) ?? String(describing: dict) + + } else if let object = content as? Encodable { + return prettyPrintEncodable(object: object) ?? String(describing: object) + + } else { + return String(describing: content) + } +} + diff --git a/CircleModularWalletsCore/Sources/Helpers/Models/UnreferencedStringEnumeration.swift b/CircleModularWalletsCore/Sources/Helpers/Models/UnreferencedStringEnumeration.swift new file mode 100644 index 0000000..fdb2c5a --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Models/UnreferencedStringEnumeration.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An enumeration type that is not referenced by other parts of the Web IDL because that would preclude other values from being used without updating the specification and its implementations. +/// - SeeAlso: [WebAuthn Level 3 Editor's Draft §2.1.1. Enumerations as DOMString types](https://w3c.github.io/webauthn/#sct-domstring-backwards-compatibility) +public protocol UnreferencedStringEnumeration: RawRepresentable, Codable, Sendable, ExpressibleByStringLiteral, Hashable, Comparable where RawValue == String { + init(_ rawValue: RawValue) +} + +extension UnreferencedStringEnumeration { + public init(rawValue: RawValue) { + self.init(rawValue) + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Utils/ErrorUtils.swift b/CircleModularWalletsCore/Sources/Helpers/Utils/ErrorUtils.swift new file mode 100644 index 0000000..fda759b --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Utils/ErrorUtils.swift @@ -0,0 +1,159 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct ErrorUtils { + + static func getRpcError(cause: RpcRequestError) -> RpcError { + + switch cause.code { + case ParseRpcError.code: + return ParseRpcError(cause: cause) + case InvalidRequestRpcError.code: + return InvalidRequestRpcError(cause: cause) + case MethodNotFoundRpcError.code: + return MethodNotFoundRpcError(cause: cause) + case InvalidParamsRpcError.code: + return InvalidParamsRpcError(cause: cause) + case InternalRpcError.code: + return InternalRpcError(cause: cause) + case InvalidInputRpcError.code: + return InvalidInputRpcError(cause: cause) + case ResourceNotFoundRpcError.code: + return ResourceNotFoundRpcError(cause: cause) + case ResourceUnavailableRpcError.code: + return ResourceUnavailableRpcError(cause: cause) + case TransactionRejectedRpcError.code: + return TransactionRejectedRpcError(cause: cause) + case MethodNotSupportedRpcError.code: + return MethodNotSupportedRpcError(cause: cause) + case LimitExceededRpcError.code: + return LimitExceededRpcError(cause: cause) + case JsonRpcVersionUnsupportedError.code: + return JsonRpcVersionUnsupportedError(cause: cause) + + case UserRejectedRequestError.code: + return UserRejectedRequestError(cause: cause) + case UnauthorizedProviderError.code: + return UnauthorizedProviderError(cause: cause) + case UnsupportedProviderMethodError.code: + return UnsupportedProviderMethodError(cause: cause) + case ProviderDisconnectedError.code: + return ProviderDisconnectedError(cause: cause) + case ChainDisconnectedError.code: + return ChainDisconnectedError(cause: cause) + case SwitchChainError.code: + return SwitchChainError(cause: cause) + + default: + return UnknownRpcError(cause: cause) + } + } + + static func getUserOperationExecutionError(err: BaseError, userOp: UserOperation?) -> BaseError { + let cause = getBundlerError(err: err, userOp: userOp) + return UserOperationExecutionError(cause: cause, + userOp: userOp ?? UserOperationV07()) + } + + static func getBundlerError(err: BaseError, userOp: UserOperation?) -> BaseError { + + // 1. Try to map BundlerError from message + + let message = err.details?.lowercased() ?? "" + + if message.contains(AccountNotDeployedError.message) { return AccountNotDeployedError(cause: err) } + else if message.contains(FailedToSendToBeneficiaryError.message) { return FailedToSendToBeneficiaryError(cause: err) } + else if message.contains(GasValuesOverflowError.message) { return GasValuesOverflowError(cause: err) } + else if message.contains(HandleOpsOutOfGasError.message) { return HandleOpsOutOfGasError(cause: err) } + else if message.contains(InitCodeFailedError.message) { + return InitCodeFailedError( + cause: err, + factory: (userOp as? UserOperationV07)?.factory, + factoryData: (userOp as? UserOperationV07)?.factoryData + ) + } + else if message.contains(InitCodeMustCreateSenderError.message) { + return InitCodeMustCreateSenderError( + cause: err, + factory: (userOp as? UserOperationV07)?.factory, + factoryData: (userOp as? UserOperationV07)?.factoryData + ) + } + else if message.contains(InitCodeMustReturnSenderError.message) { + return InitCodeMustReturnSenderError( + cause: err, + factory: (userOp as? UserOperationV07)?.factory, + factoryData: (userOp as? UserOperationV07)?.factoryData, + sender: (userOp as? UserOperationV07)?.sender + ) + } + else if message.contains(InsufficientPrefundError.message) { return InsufficientPrefundError(cause: err) } + else if message.contains(InternalCallOnlyError.message) { return InternalCallOnlyError(cause: err) } + else if message.contains(InvalidAggregatorError.message) { return InvalidAggregatorError(cause: err) } + else if message.contains(InvalidAccountNonceError.message) { + return InvalidAccountNonceError(cause: err, nonce: userOp?.nonce) + } + else if message.contains(InvalidBeneficiaryError.message) { return InvalidBeneficiaryError(cause: err) } + else if message.contains(InvalidPaymasterAndDataError.message) { return InvalidPaymasterAndDataError(cause: err) } + else if message.contains(PaymasterDepositTooLowError.message) { return PaymasterDepositTooLowError(cause: err) } + else if message.contains(PaymasterFunctionRevertedError.message) { return PaymasterFunctionRevertedError(cause: err) } + else if message.contains(PaymasterNotDeployedError.message) { return PaymasterNotDeployedError(cause: err) } + else if message.contains(PaymasterPostOpFunctionRevertedError.message) { return PaymasterPostOpFunctionRevertedError(cause: err) } + else if message.contains(SenderAlreadyConstructedError.message) { + return SenderAlreadyConstructedError( + cause: err, + factory: (userOp as? UserOperationV07)?.factory, + factoryData: (userOp as? UserOperationV07)?.factoryData + ) + } + else if message.contains(SmartAccountFunctionRevertedError.message) { return SmartAccountFunctionRevertedError(cause: err) } + else if message.contains(UserOperationExpiredError.message) { return UserOperationExpiredError(cause: err) } + else if message.contains(UserOperationPaymasterExpiredError.message) { return UserOperationPaymasterExpiredError(cause: err) } + else if message.contains(UserOperationSignatureError.message) { return UserOperationSignatureError(cause: err) } + else if message.contains(UserOperationPaymasterSignatureError.message) { return UserOperationPaymasterSignatureError(cause: err) } + else if message.contains(VerificationGasLimitExceededError.message) { return VerificationGasLimitExceededError(cause: err) } + else if message.contains(VerificationGasLimitTooLowError.message) { return VerificationGasLimitTooLowError(cause: err) } + + // 2. Try to map BundlerError from error code + + if let rpcError = err as? RpcRequestError { + + switch rpcError.code { + case ExecutionRevertedError.code: return ExecutionRevertedError(cause: err, message: err.details) + case InvalidFieldsError.code: return InvalidFieldsError(cause: err) + case PaymasterDepositTooLowError.code: return PaymasterDepositTooLowError(cause: err) + case PaymasterRateLimitError.code: return PaymasterRateLimitError(cause: err) + case PaymasterStakeTooLowError.code: return PaymasterStakeTooLowError(cause: err) + case SignatureCheckFailedError.code: return SignatureCheckFailedError(cause: err) + case UnsupportedSignatureAggregatorError.code: return UnsupportedSignatureAggregatorError(cause: err) + case UserOperationRejectedByEntryPointError.code: return UserOperationRejectedByEntryPointError(cause: err) + case UserOperationRejectedByPaymasterError.code: return UserOperationRejectedByPaymasterError(cause: err) + case UserOperationRejectedByOpCodeError.code: return UserOperationRejectedByOpCodeError(cause: err) + case UserOperationOutOfTimeRangeError.code: return UserOperationOutOfTimeRangeError(cause: err) + default: break + } + } + + // 3. Error message or error code not found. + + return UnknownBundlerError(cause: err) + } + +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Utils/HexUtils.swift b/CircleModularWalletsCore/Sources/Helpers/Utils/HexUtils.swift new file mode 100644 index 0000000..c40671c --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Utils/HexUtils.swift @@ -0,0 +1,106 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +enum HexConversionError: Error { + case invalidDigit + case stringNotEven +} + +struct HexUtils { + + static func intToHex(_ int: Int?, withPrefix: Bool = true) -> String { + guard let int else { return withPrefix ? "0x" : "" } + let hexString = String(format: "%x", int) + return withPrefix ? "0x" + hexString : hexString + } + + static func dataToHex(_ data: Data?, withPrefix: Bool = true) -> String { + guard let data else { return withPrefix ? "0x" : "" } + let hexString = data.map { String(format: "%02hhx", $0) }.joined() + return withPrefix ? "0x" + hexString : hexString + } + + static func bytesToHex(_ bytes: [UInt8]?, withPrefix: Bool = true) -> String { + guard let bytes else { return withPrefix ? "0x" : "" } + let hexString = bytes.map { String(format: "%02hhx", $0) }.joined() + return withPrefix ? "0x" + hexString : hexString + } + + static func hexToInt(hex: String?) -> Int? { + guard let hex else { return nil } + return Int(hex.noHexPrefix, radix: 16) + } + + static func hexToBigInt(hex: String?) -> BigInt? { + guard let hex else { return nil } + return BigInt(hex.noHexPrefix, radix: 16) + } + + static func bigIntToHex(_ bigInt: BigInt, withPrefix: Bool = true) -> String { + let hex = BigUInt(bigInt).hexString + return withPrefix ? hex : hex.noHexPrefix + } + + static func hexToData(hex: String?) -> Data? { + guard let hex else { return nil } + guard let bytes = try? HexUtils.hexToBytes(hex: hex.noHexPrefix) else { + return nil + } + + return Data(bytes) + } + + static func hexToBytes(hex string: String) throws -> [UInt8] { + var iterator = string.noHexPrefix.unicodeScalars.makeIterator() + var byteArray: [UInt8] = [] + + while let msn = iterator.next() { + if let lsn = iterator.next() { + do { + let convertedMsn = try convert(hexDigit: msn) + let convertedLsn = try convert(hexDigit: lsn) + byteArray += [convertedMsn << 4 | convertedLsn] + } catch { + throw error + } + } else { + throw HexConversionError.stringNotEven + } + } + return byteArray + } + + private static func convert(hexDigit digit: UnicodeScalar) throws -> UInt8 { + switch digit { + case UnicodeScalar(unicodeScalarLiteral: "0") ... UnicodeScalar(unicodeScalarLiteral: "9"): + return UInt8(digit.value - UnicodeScalar(unicodeScalarLiteral: "0").value) + + case UnicodeScalar(unicodeScalarLiteral: "a") ... UnicodeScalar(unicodeScalarLiteral: "f"): + return UInt8(digit.value - UnicodeScalar(unicodeScalarLiteral: "a").value + 0xa) + + case UnicodeScalar(unicodeScalarLiteral: "A") ... UnicodeScalar(unicodeScalarLiteral: "F"): + return UInt8(digit.value - UnicodeScalar(unicodeScalarLiteral: "A").value + 0xa) + + default: + throw HexConversionError.invalidDigit + } + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Utils/UnitUtils.swift b/CircleModularWalletsCore/Sources/Helpers/Utils/UnitUtils.swift new file mode 100644 index 0000000..bbb8ecf --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Utils/UnitUtils.swift @@ -0,0 +1,66 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt +import Web3Core + +enum UnitUtilsError: Error { + case invalidValueString + case negativeValueNotSupported + case invalidDigitLength +} + +struct UnitUtils { + + /// Parse positive Gwei value to Wei value + /// - Parameter value: Gwei value + /// - Returns: Wei value (negative values will throw error) + static func parseGweiToWei(_ value: String) throws -> BigInt { + guard let wei = Utilities.parseToBigUInt(value, units: .gwei) else { + guard let num = Double(value) else { + throw UnitUtilsError.invalidValueString + } + switch num { + case ..<0: + throw UnitUtilsError.negativeValueNotSupported + default: + throw UnitUtilsError.invalidDigitLength + } + } + return BigInt(wei) + } + + /// Parse positive Ether value to Wei value + /// - Parameter value: Ether value + /// - Returns: Wei value (negative values will throw error) + static func parseEtherToWei(_ value: String) throws -> BigInt { + guard let wei = Utilities.parseToBigUInt(value, units: .ether) else { + guard let num = Double(value) else { + throw UnitUtilsError.invalidValueString + } + switch num { + case ..<0: + throw UnitUtilsError.negativeValueNotSupported + default: + throw UnitUtilsError.invalidDigitLength + } + } + return BigInt(wei) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Utils/Utils.swift b/CircleModularWalletsCore/Sources/Helpers/Utils/Utils.swift new file mode 100644 index 0000000..013a4a4 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Utils/Utils.swift @@ -0,0 +1,831 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Web3Core +import web3swift +import BigInt +import CryptoKit + +public struct Utils { + + /// Encode abi function + /// - Parameters: + /// - functionName: ABI function name + /// - abiJson: ABI json + /// - args: Input array for the ABI function + /// - Returns: Encoded ABI function + public static func encodeFunctionData(functionName: String, + abiJson: String, + args: [Any]) -> String? { + guard let contract = try? EthereumContract(abiJson), + let callData = contract.method(functionName, parameters: args, extraData: nil) else { + logger.utils.notice("This abiJson cannot be parsed or the given contract method cannot be called with the given parameters") + return nil + } + + logger.utils.notice("callData:\n\(HexUtils.dataToHex(callData))") + + return HexUtils.dataToHex(callData) + } + + /// Verifies a signature using the credential public key and the hash which was signed. + /// - Parameters: + /// - hash: (hex) string + /// - publicKey: (serialized hex) string + /// - signature: (serialized hex) string + /// - webauthn: WebAuthnData + /// - Returns: Verification success or failure + public static func verify(hash: String, + publicKey: String, + signature: String, + webauthn: WebAuthnData) throws -> Bool { + do { + let rawClientData = webauthn.clientDataJSON.bytes + let clientData = try JSONDecoder().decode(CollectedClientData.self, from: Data(rawClientData)) + let rawAuthenticatorData = try HexUtils.hexToBytes(hex: webauthn.authenticatorData) + let authenticatorData = try AuthenticatorData(bytes: rawAuthenticatorData) + + guard let expectedChallengeData = HexUtils.hexToData(hex: hash) else { + logger.utils.error("Failed to decode the hash (\"\(hash)\") hex string into Data struct.") + return false + } + + let publicKeyBytes = try HexUtils.hexToBytes(hex: publicKey) + + guard let signature = HexUtils.hexToData(hex: signature) else { + logger.utils.error("Failed to decode the signature (\"\(signature)\") hex string into Data struct.") + return false + } + + try _verify( + clientData: clientData, + rawClientData: rawClientData, + authenticatorData: authenticatorData, + rawAuthenticatorData: rawAuthenticatorData, + requireUserVerification: webauthn.userVerificationRequired, + expectedChallenge: expectedChallengeData.bytes, + credentialPublicKey: publicKeyBytes, + signature: signature + ) + return true + } catch let error as DecodingError { + throw BaseError(shortMessage: "Failed to get the CollectedClientData object from rawClientData.", + args: .init(cause: error, name: String(describing: error))) + } catch let error as HexConversionError { + throw BaseError(shortMessage: "Failed to decode the authenticatorData/publicKey hex string into UInt8 array.", + args: .init(cause: error, name: String(describing: error))) + } catch let error as WebAuthnError { + throw BaseError(shortMessage: "Failed to get the AuthenticatorData object from rawAuthenticatorData.", + args: .init(cause: error, name: String(describing: error))) + } catch let error as BaseError { + logger.webAuthn.notice("Error: \(error)") + throw error + } catch { + logger.webAuthn.notice("Error: \(error)") + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } + + public static func getUserOperationHash( + chainId: Int, + entryPointAddress: String = ENTRYPOINT_V07_ADDRESS, + userOp: UserOperationV07 + ) -> String { + + var accountGasLimits = [UInt8]() + if let verificationGasLimit = userOp.verificationGasLimit, + let callGasLimit = userOp.callGasLimit { + + let verificationGasLimitHex = HexUtils.bigIntToHex(verificationGasLimit, withPrefix: false) + let verificationGasLimitHexWithPadding = verificationGasLimitHex.leftPadding(toLength: 32, withPad: "0") + let callGasLimitHex = HexUtils.bigIntToHex(callGasLimit, withPrefix: false) + let callGasLimitHexWithPadding = callGasLimitHex.leftPadding(toLength: 32, withPad: "0") + let finalHex = verificationGasLimitHexWithPadding + callGasLimitHexWithPadding + + if let accountGasLimitsData = HexUtils.hexToData(hex: finalHex) { + accountGasLimits = accountGasLimitsData.bytes + } + } + + var callDataHashed = Data() + if let callDataHex = userOp.callData, + let callData = HexUtils.hexToData(hex: callDataHex) { + callDataHashed = callData.sha3(.keccak256) + } + + var gasFees = [UInt8]() + if let maxPriorityFeePerGas = userOp.maxPriorityFeePerGas, + let maxFeePerGas = userOp.maxFeePerGas { + + let maxPriorityFeePerGasHex = HexUtils.bigIntToHex(maxPriorityFeePerGas, withPrefix: false) + let maxPriorityFeePerGasHexWithPadding = maxPriorityFeePerGasHex.leftPadding(toLength: 32, withPad: "0") + let maxFeePerGasHex = HexUtils.bigIntToHex(maxFeePerGas, withPrefix: false) + let maxFeePerGasHexWithPadding = maxFeePerGasHex.leftPadding(toLength: 32, withPad: "0") + let finalHex = maxPriorityFeePerGasHexWithPadding + maxFeePerGasHexWithPadding + + if let gasFeesData = HexUtils.hexToData(hex: finalHex) { + gasFees = gasFeesData.bytes + } + } + + var initCodeHashed = Data() + var initCodeHex = "0x" + if let factory = userOp.factory, + let factoryData = userOp.factoryData { + initCodeHex = factory.noHexPrefix + factoryData.noHexPrefix + } + + if let initCodeData = HexUtils.hexToData(hex: initCodeHex) { + initCodeHashed = initCodeData.sha3(.keccak256) + } + + var paymasterAndDataHashed = Data() + var paymasterAndDataHex = "0x" + if let paymaster = userOp.paymaster { + + let paymasterVerificationGasLimit = userOp.paymasterVerificationGasLimit ?? BigInt.zero + let verificationGasLimitHex = HexUtils.bigIntToHex(paymasterVerificationGasLimit, withPrefix: false) + let verificationGasLimitHexWithPadding = verificationGasLimitHex.leftPadding(toLength: 32, withPad: "0") + + let paymasterPostOpGasLimit = userOp.paymasterPostOpGasLimit ?? BigInt.zero + let postOpGasLimitHex = HexUtils.bigIntToHex(paymasterPostOpGasLimit, withPrefix: false) + let postOpGasLimitHexWithPadding = postOpGasLimitHex.leftPadding(toLength: 32, withPad: "0") + + let paymasterData = userOp.paymasterData?.noHexPrefix ?? "" + + paymasterAndDataHex = paymaster + verificationGasLimitHexWithPadding + postOpGasLimitHexWithPadding + paymasterData + } + + if let paymasterAndData = HexUtils.hexToData(hex: paymasterAndDataHex) { + paymasterAndDataHashed = paymasterAndData.sha3(.keccak256) + } + + var types: [ABI.Element.ParameterType] = [ + .address, + .uint(bits: 256), + .bytes(length: 32), + .bytes(length: 32), + .bytes(length: 32), + .uint(bits: 256), + .bytes(length: 32), + .bytes(length: 32) + ] + + var values = [Any]() + if let sender = userOp.sender, + let nonce = userOp.nonce, + let preVerificationGas = userOp.preVerificationGas { + values = [ + sender, + nonce, + initCodeHashed, + callDataHashed, + accountGasLimits, + preVerificationGas, + gasFees, + paymasterAndDataHashed + ] + } + + var packedUserOpData = Data() + if let _packedUserOpData = ABIEncoder.encode(types: types, values: values) { + packedUserOpData = _packedUserOpData + } + + let packedUserOpHashed = packedUserOpData.sha3(.keccak256) + + logger.utils.debug("packedUserOp hashed : \(HexUtils.dataToHex(packedUserOpHashed))") + + types = [.bytes(length: 32), .address, .uint(bits: 256)] + values = [packedUserOpHashed, entryPointAddress, chainId] + + var userOpData = Data() + if let _userOpData = ABIEncoder.encode(types: types, values: values) { + userOpData = _userOpData + } + + let userOpHashed = userOpData.sha3(.keccak256) + + return HexUtils.dataToHex(userOpHashed) + } + + public static func encodeTransfer(to: String, + token: String, + amount: BigInt) -> String { + let abiParameters: [Any] = [to, amount] + + let encodedAbi = self.encodeFunctionData( + functionName: "transfer", + abiJson: ABI_ERC20, + args: abiParameters + ) + + let arg = EncodeCallDataArg(to: CONTRACT_ADDRESS[token] ?? token, + value: BigInt.zero, + data: encodedAbi) + + return encodeCallData(arg: arg) + } + + public static func encodeContractExecution( + to: String, + abiSignature: String, + args: [Any] = [], + value: BigInt + ) -> String { + let signatureParts = abiSignature.split(separator: "(", maxSplits: 1) + let functionName = String(signatureParts[0]) + let parameterTypesString = signatureParts[1].dropLast() + let parameterTypes = parseParameterTypes(String(parameterTypesString)) + let function = ABI.Element.Function(name: functionName, + inputs: parameterTypes, + outputs: [], + constant: false, + payable: false) + + guard let encodedABI = function.encodeParameters(args) else { + logger.utils.notice("Failed to encode parameters (\(args) of a given contract method") + return "" + } + + let arg = EncodeCallDataArg( + to: to, + value: value, + data: HexUtils.dataToHex(encodedABI) + ) + + return Utils.encodeCallData(arg: arg) + } +} + +extension Utils { + + private typealias InOut = ABI.Element.InOut + + /// This is an imitation of the structure and methods of ABI.Input, as its + /// original structure and methods are intended for internal use within + /// a third-party library. + private struct ABIInput { + var name: String? + var type: String + var components: [ABIInput]? + + func parse() throws -> InOut { + let name = self.name ?? "" + let parameterType = try ABITypeParser.parseTypeString(self.type) + if case .tuple(types: _) = parameterType { + let components = try self.components?.compactMap({ (inp: ABIInput) throws -> ABI.Element.ParameterType in + let input = try inp.parse() + return input.type + }) + let type = ABI.Element.ParameterType.tuple(types: components!) + let nativeInput = InOut(name: name, type: type) + return nativeInput + } else if case .array(type: .tuple(types: _), length: _) = parameterType { + let components = try self.components?.compactMap({ (inp: ABIInput) throws -> ABI.Element.ParameterType in + let input = try inp.parse() + return input.type + }) + let tupleType = ABI.Element.ParameterType.tuple(types: components!) + + let newType: ABI.Element.ParameterType = .array(type: tupleType, length: 0) + let nativeInput = InOut(name: name, type: newType) + return nativeInput + } else { + let nativeInput = InOut(name: name, type: parameterType) + return nativeInput + } + } + } + + // MARK: Internal Usage + + static func _verify( + clientData: CollectedClientData, + rawClientData: [UInt8], + authenticatorData: AuthenticatorData, + rawAuthenticatorData: [UInt8], + requireUserVerification: Bool, + expectedChallenge: [UInt8], + credentialPublicKey: [UInt8], + signature: Data + ) throws { + try clientData.verify(storedChallenge: expectedChallenge, + ceremonyType: .assert) + + guard authenticatorData.flags.userPresent else { throw WebAuthnError.userPresentFlagNotSet } + if requireUserVerification { + guard authenticatorData.flags.userVerified else { throw WebAuthnError.userVerifiedFlagNotSet } + } + + let clientDataHash = SHA256.hash(data: rawClientData) + let signatureBase = rawAuthenticatorData + clientDataHash + + let credentialPublicKey = try CredentialPublicKey(publicKeyBytes: credentialPublicKey) + try credentialPublicKey.verify(signature: signature, data: signatureBase) + } + + /// ABI JSON: + /// https://github.com/wevm/viem/blob/main/src/account-abstraction/accounts/implementations/toCoinbaseSmartAccount.ts#L553-L562 + static func encodeCallData(arg: EncodeCallDataArg) -> String { + let functionName = "execute" + let input1 = InOut(name: "target", type: .address) + let input2 = InOut(name: "value", type: .uint(bits: 256)) + let input3 = InOut(name: "data", type: .dynamicBytes) + let function = ABI.Element.Function(name: functionName, + inputs: [input1, input2, input3], + outputs: [], + constant: false, + payable: true) + let params: [Any] = [arg.to, arg.value ?? BigInt(0), arg.data ?? "0x"] + let encodedData = function.encodeParameters(params) + return HexUtils.dataToHex(encodedData) + } + + /// ABI JSON: + /// https://github.com/wevm/viem/blob/main/src/account-abstraction/accounts/implementations/toCoinbaseSmartAccount.ts#L563-L580 + /// Logic: + /// https://github.com/wevm/viem/blob/main/src/account-abstraction/accounts/implementations/toCoinbaseSmartAccount.ts#L122-L140 + static func encodeCallData(args: [EncodeCallDataArg]) -> String { + if args.count == 1 { + return encodeCallData(arg: args[0]) + } + + let functionName = "executeBatch" + let tupleTypes: [ABI.Element.ParameterType] = [ + .address, + .uint(bits: 256), + .dynamicBytes + ] + let input = InOut(name: "calls", type: .array(type: .tuple(types: tupleTypes), length: 0)) + let function = ABI.Element.Function(name: functionName, + inputs: [input], + outputs: [], + constant: false, + payable: false) + + let params: [Any] = args.map { + [$0.to, $0.value ?? BigInt(0), $0.data ?? "0x"] + } + let encodedData = function.encodeParameters([params]) + return HexUtils.dataToHex(encodedData) + } + + static func encodePacked(_ parameters: [Any]) -> String { + let encoded = parameters.reduce("") { (partialResult, parameter) in + return partialResult + HexUtils.dataToHex(try? ABIEncoder.abiEncode(parameter), withPrefix: false) + } + return encoded + } + + static func hashMessage(hex: String) -> String { + var bytes = [UInt8]() + + if let _bytes = try? HexUtils.hexToBytes(hex: hex) { + bytes = _bytes + } + + return hashMessage(byteArray: bytes) + } + + static func hashMessage(byteArray: [UInt8]) -> String { + let hash = Utilities.hashPersonalMessage(Data(byteArray)) + return HexUtils.dataToHex(hash) + } + + static func parseFactoryAddressAndData(initCode: String) -> (factoryAddress: String, factoryData: String) { + guard initCode.count >= 42 else { + logger.utils.error("initCode must be at least 42 characters long") + return ("", "") + } + + let factoryAddress = String(initCode.prefix(42)) + let factoryData = "0x\(initCode.dropFirst(42))" + + return (factoryAddress, factoryData) + } + + /// It can handle 1 level (depth=1) tuples of abiSignature + /// For future work, it should be able to handle n-level (depth = n) tuples + static func parseParameterTypes(_ typesString: String) -> [ABI.Element.InOut] { + var types: [InOut] = [] + var currentTypeStr = "" + var currentOriType: ABIInput? + var depth = 0 + + for char in typesString { + switch char { + case "(": + depth += 1 + case ")": + depth -= 1 + if depth == 0 { + if currentOriType == nil { + currentOriType = .init(type: currentTypeStr) + if let inOut = try? currentOriType?.parse() { + types.append(inOut) + } + } else { + if currentOriType?.components == nil { + currentOriType?.components = [.init(type: currentTypeStr)] + } else { + currentOriType?.components?.append(.init(type: currentTypeStr)) + } + + if let inOut = try? currentOriType?.parse() { + types.append(inOut) + } + } + currentTypeStr = "" + currentOriType = nil + } else { + if currentOriType == nil { + currentOriType = .init(type: "tuple") + } + + if currentOriType?.components == nil { + currentOriType?.components = [.init(type: currentTypeStr)] + } else { + currentOriType?.components?.append(.init(type: currentTypeStr)) + } + } + case ",": + if depth == 0 { + if !currentTypeStr.isEmpty { + if currentOriType == nil { + currentOriType = .init(type: currentTypeStr) + if let inOut = try? currentOriType?.parse() { + types.append(inOut) + currentOriType = nil + } + } else { + if currentOriType?.components == nil { + currentOriType?.components = [.init(type: currentTypeStr)] + } else { + currentOriType?.components?.append(.init(type: currentTypeStr)) + } + } + } + } else { + if currentOriType == nil { + currentOriType = .init(type: "tuple") + } + + if currentOriType?.components == nil { + currentOriType?.components = [.init(type: currentTypeStr)] + } else { + currentOriType?.components?.append(.init(type: currentTypeStr)) + } + } + + currentTypeStr = "" + default: + currentTypeStr.append(char) + } + } + + if !currentTypeStr.isEmpty { + if currentOriType == nil { + currentOriType = .init(type: currentTypeStr) + if let inOut = try? currentOriType?.parse() { + types.append(inOut) + currentOriType = nil + } + } + } + + return types + } + + enum PollingError: Error { + case timeout + case noResult + } + + /// Starts polling for a result by repeatedly executing a given asynchronous block. + /// + /// - Parameters: + /// - pollingInterval: The time interval in milliseconds between each polling attempt. + /// - retryCount: The maximum number of times to retry polling if a result is not obtained. + /// - timeout: An optional timeout period in seconds. If provided, the polling will stop if this duration is exceeded. + /// - block: An asynchronous closure that returns a value of type T? which will be polled repeatedly. + /// + /// - Throws: + /// - PollingError.timeoutError if the polling operation exceeds the specified timeout period. + /// + /// - Returns: An optional value of type T? if a result is obtained within the allowed polling attempts and timeout period. + static func startPolling(pollingInterval: Int, + retryCount: Int, + timeout: Int?, + block: @escaping () async throws -> T) async throws -> T { + + logger.utils.debug("Start polling, retryCount: \(retryCount)") + + var currentCount = 0 + let startTime = Date() // Record the start time + + while currentCount < retryCount { + logger.utils.debug("Polling currentCount: \(currentCount)") + + if let result = try? await block() { + logger.utils.debug("Polling got result: \(currentCount)") + return result + } + + // Check if the timeout has been exceeded + if let timeout = timeout, Date().timeIntervalSince(startTime) > Double(timeout) { + throw PollingError.timeout + } + + currentCount += 1 + try? await Task.sleep(nanoseconds: UInt64(pollingInterval) * 1_000_000) // Convert milliseconds to nanoseconds + } + + throw PollingError.noResult + } + + static func pemToCOSE(pemKey: String) throws -> [UInt8] { + // 1. Decode Base64URL string + guard let keyBytes = URLEncodedBase64(pemKey).decodedBytes else { + throw BaseError(shortMessage: "PEMToCOSE(pemKey: \"\(pemKey)\" Invalid Base64URL encoding") + } + + // 2. Composition PEM Document format + let pemStrs = Data(keyBytes).base64EncodedString().split(every: 64) + var pemDocument = "-----BEGIN PUBLIC KEY-----\n" + pemStrs.forEach { + pemDocument += $0 + "\n" + } + pemDocument += "-----END PUBLIC KEY-----" + + do { + // 3. Parse public key data + let publicKey = try P256.Signing.PublicKey(pemRepresentation: pemDocument) + + // 4. Construct COSE format + var coseKey: [UInt8] = [] + + // COSE Key Common Parameters + coseKey.append(contentsOf: [ + 0xa5, // map of 5 pairs + 0x01, 0x02, // kty: EC2 + 0x03, 0x26, // alg: ES256 (-7) in CBOR encoding + 0x20, 0x01, // crv: P-256 + ]) + + // Public Key X coordinate + coseKey.append(0x21) // x coordinate (negative integer for key -2) + coseKey.append(0x58) // bytes + coseKey.append(0x20) // 32 bytes + coseKey.append(contentsOf: publicKey.rawRepresentation.prefix(32)) + + // Public Key Y coordinate + coseKey.append(0x22) // y coordinate (negative integer for key -3) + coseKey.append(0x58) // bytes + coseKey.append(0x20) // 32 bytes + coseKey.append(contentsOf: publicKey.rawRepresentation.suffix(32)) + + return coseKey + } catch { + throw BaseError(shortMessage: "Failed to parse public key data (\(error))", + args: .init(cause: error, name: String(describing: error))) + } + } + + // Function to extract r and s from a DER-encoded ECDSA signature + static func extractRSFromDER(_ signatureHex: String) -> (r: Data, s: Data)? { + + // 1. The signature in DER format should be encoded using ASN.1. + guard let signatureData = HexUtils.hexToData(hex: signatureHex) else { + logger.utils.notice("Invalid hexadecimal signature string") + return nil + } + + // 2. Parse the DER-encoded signature + var offset = 0 + + // 3. Check the signature starts with the correct identifier for a SEQUENCE (0x30) + guard signatureData[offset] == 0x30 else { + + logger.utils.notice("Invalid signature") + return nil + } + offset += 1 + + // 4. Check that the length of the SEQUENCE matches the total length of the signature data + guard signatureData[offset] == signatureData.count - 2 else { + logger.utils.notice("Invalid signature") + return nil + } + offset += 1 + + // 5. Check for the INTEGER identifier (0x02) and reads the length of r, + // then extracts the corresponding bytes. + guard signatureData[offset] == 0x02 else { + logger.utils.notice("Invalid signature") + return nil + } + offset += 1 + + let rLength = Int(signatureData[offset]) + offset += 1 + + let r = signatureData[offset.. String { + // 1. DER encoding format + let rLength = signature.r.count + let sLength = signature.s.count + + // 2. Build the DER encoded data + var der = Data() + + // 3. Add sequence tag + der.append(0x30) // SEQUENCE + der.append(UInt8(rLength + sLength + 4)) // Length of the entire sequence + + // 4. Add r + der.append(0x02) // INTEGER + der.append(UInt8(rLength)) // Length of r + der.append(signature.r) // Value of s + + // 5. Add s + der.append(0x02) // INTEGER + der.append(UInt8(sLength)) // Length of s + der.append(signature.s) // Value of s + + return HexUtils.dataToHex(der) + } + + static func toData(value: String) -> Data { + let data: Data + + if value.isHex { + data = HexUtils.hexToData(hex: value) ?? .init() + } else { + data = Data(value.utf8) + } + + return data + } + + static func toSha3Data(message: String) -> Data { + let digest: Data + + if message.isHex { + digest = (HexUtils.hexToData(hex: message) ?? .init()).sha3(.keccak256) + } else { + digest = Data(message.utf8).sha3(.keccak256) + } + + return digest + } + + // Function to pad Data to a specified size + static func pad(data: Data, size: Int = 32, isRight: Bool = false) -> Data { + // Check if the size of bytes exceeds the specified size + if data.count > size { + return data.suffix(size) + } + + // Create a Data object for padded bytes + var paddedData = Data(repeating: 0, count: size) + + for i in 0.. Bool { + let digest = toSha3Data(message: message) + + let functionName = "isValidSignature" + let input1 = InOut(name: "digest", type: .bytes(length: 32)) + let input2 = InOut(name: "signature", type: .dynamicBytes) + let output = InOut(name: "magicValue", type: .bytes(length: 4)) + let function = ABI.Element.Function(name: functionName, + inputs: [input1, input2], + outputs: [output], + constant: false, + payable: false) + let params: [Any] = [digest, signature] + guard let data = function.encodeParameters(params) else { + logger.utils.notice("isValidSignature function encodeParameters failure") + return false + } + + guard let fromAddress = EthereumAddress(from), let toAddress = EthereumAddress(to) else { + logger.utils.notice("Invalid 'from' or 'to' address") + return false + } + + var transaction = CodableTransaction(to: toAddress, data: data) + transaction.from = fromAddress + + guard let callResult = try? await Utils().ethCall(transport: transport, + transaction: transaction) else { + logger.utils.notice("Failed to execute eth_call request") + return false + } + + guard let callResultData = HexUtils.hexToData(hex: callResult), + let decoded = try? function.decodeReturnData(callResultData) else { + logger.utils.notice("isValidSignature function decodeReturnData failure") + return false + } + + guard let magicValue = decoded["0"] as? Data else { + logger.utils.notice("The data type returned by eth_call request is incorrect") + return false + } + + return EIP1271_VALID_SIGNATURE == magicValue.bytes + } +} + +extension Utils: PublicRpcApi { + + static func getNonce(transport: Transport, + address: String, + entryPoint: EntryPoint, + key: BigUInt = BigUInt(0)) async throws -> BigInt { + let functionName = "getNonce" + let input1 = InOut(name: "address", type: .address) + let input2 = InOut(name: "key", type: .uint(bits: 192)) + let output = InOut(name: "object", type: .uint(bits: 256)) + let function = ABI.Element.Function(name: functionName, + inputs: [input1, input2], + outputs: [output], + constant: false, + payable: false) + let params: [Any] = [address, 0] + + guard let toAddress = EthereumAddress(entryPoint.address) else { + throw BaseError(shortMessage: "Invalid 'to' address (\(entryPoint.address))") + } + + guard let data = function.encodeParameters(params) else { + throw BaseError(shortMessage: "Failed to encode parameters (\(params) of a given contract method") + } + logger.utils.debug("getNonce function encoded data:\n\(data.toHexString())") + + let transaction = CodableTransaction(to: toAddress, data: data) + let callResult = try await Utils().ethCall(transport: transport, + transaction: transaction) + guard let bigInt = HexUtils.hexToBigInt(hex: callResult) else { + let error = CommonError.invalidHexString + throw BaseError(shortMessage: "Failed to convert the hex (\"\(callResult)\") string to BigInt", + args: .init(cause: error, name: String(describing: error))) + } + + return bigInt + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/Utils/WebAuthnUtils.swift b/CircleModularWalletsCore/Sources/Helpers/Utils/WebAuthnUtils.swift new file mode 100644 index 0000000..fe41ca9 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/Utils/WebAuthnUtils.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct WebAuthnUtils { + + static func getRequestOption( + rpId: String, + allowCredentialId: String? = nil, + hex: String + ) throws -> PublicKeyCredentialRequestOptions { + guard let challengeData = HexUtils.hexToData(hex: hex) else { + throw BaseError(shortMessage: "Failed to get requestOption, hexToData(hex: \"\(hex)\") conversion failure") + } + + let challenge = challengeData.base64URLEncodedString() + var allowCredentials: [PublicKeyCredentialDescriptor]? = nil + if let allowCredentialId { + allowCredentials = [PublicKeyCredentialDescriptor(id: allowCredentialId)] + } + + let option = PublicKeyCredentialRequestOptions( + challenge: challenge, + relyingParty: .init(id: rpId, name: rpId), + allowCredentials: allowCredentials, + userVerification: .required + ) + + return option + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/AuthenticationCredential.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/AuthenticationCredential.swift new file mode 100644 index 0000000..1bf27a9 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/AuthenticationCredential.swift @@ -0,0 +1,102 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// The unprocessed response received from `navigator.credentials.get()`. +public struct AuthenticationCredential: PublicKeyCredential, Codable, Sendable { + + /// The credential ID of the newly created credential. + public let id: String + + /// Value will always be ``CredentialType/publicKey`` (for now) + public let type: CredentialType + + /// An authenticators' attachment modalities. + public let authenticatorAttachment: AuthenticatorAttachment? + + /// The raw credential ID of the newly created credential. + public let rawID: URLEncodedBase64 + + /// The attestation response from the authenticator. + /// In fact, it is stored in AuthenticatorAssertionResponse + public let response: AuthenticatorResponse + + /// Reports the authenticator attachment modality in effect at the time the navigator.credentials.create() or + /// navigator.credentials.get() methods successfully complete + public let clientExtensionResults: AuthenticationExtensionsClientOutputs? + + private enum CodingKeys: String, CodingKey { + case id + case type + case authenticatorAttachment + case rawID = "rawId" + case response + case clientExtensionResults + } + + init(id: String, + type: CredentialType, + authenticatorAttachment: AuthenticatorAttachment? = nil, + rawID: URLEncodedBase64, + response: AuthenticatorAssertionResponse, + clientExtensionResults: AuthenticationExtensionsClientOutputs? = nil) { + self.id = id + self.type = type + self.authenticatorAttachment = authenticatorAttachment + self.rawID = rawID + self.response = response + self.clientExtensionResults = clientExtensionResults + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + type = try container.decode(CredentialType.self, forKey: .type) + authenticatorAttachment = try container.decode(AuthenticatorAttachment.self, forKey: .authenticatorAttachment) + rawID = try container.decode(URLEncodedBase64.self, forKey: .rawID) + response = try container.decode(AuthenticatorAssertionResponse.self, forKey: .response) + clientExtensionResults = try container.decode(AuthenticationExtensionsClientOutputs.self, forKey: .clientExtensionResults) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(authenticatorAttachment, forKey: .authenticatorAttachment) + try container.encode(rawID.asString(), forKey: .rawID) + try container.encode(response, forKey: .response) + try container.encodeIfPresent(clientExtensionResults, forKey: .clientExtensionResults) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/AuthenticatorAssertionResponse.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/AuthenticatorAssertionResponse.swift new file mode 100644 index 0000000..5f392b0 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/AuthenticatorAssertionResponse.swift @@ -0,0 +1,122 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import CryptoKit + +/// This is what the authenticator device returned after we requested it to authenticate a user. +public struct AuthenticatorAssertionResponse: AuthenticatorResponse, Encodable, Sendable { + /// Representation of what we passed to `navigator.credentials.get()` + public let clientDataJSON: URLEncodedBase64 + + /// Contains the authenticator data returned by the authenticator. + public let authenticatorData: URLEncodedBase64 + + /// Contains the raw signature returned from the authenticator + public let signature: URLEncodedBase64 + + /// Contains the user handle returned from the authenticator, or null if the authenticator did not return + /// a user handle. Used by to give scope to credentials. + public let userHandle: URLEncodedBase64? +} + +struct ParsedAuthenticatorAssertionResponse: Sendable { + let rawClientData: [UInt8] + let clientData: CollectedClientData + let rawAuthenticatorData: [UInt8] + let authenticatorData: AuthenticatorData + let signature: URLEncodedBase64 + let userHandle: [UInt8]? + + init(from authenticatorAssertionResponse: AuthenticatorAssertionResponse) throws { + + if let rawClientData = authenticatorAssertionResponse.clientDataJSON.decodedBytes { + self.rawClientData = rawClientData + } else { + self.rawClientData = [] + } + + clientData = try JSONDecoder().decode(CollectedClientData.self, from: Data(rawClientData)) + + if let rawAuthenticatorData = authenticatorAssertionResponse.authenticatorData.decodedBytes { + self.rawAuthenticatorData = rawAuthenticatorData + } else { + self.rawAuthenticatorData = [] + } + + authenticatorData = try AuthenticatorData(bytes: self.rawAuthenticatorData) + signature = authenticatorAssertionResponse.signature + userHandle = authenticatorAssertionResponse.userHandle?.decodedBytes + } + + // swiftlint:disable:next function_parameter_count + func verify( + expectedChallenge: [UInt8], + relyingPartyOrigin: String, + relyingPartyID: String, + requireUserVerification: Bool, + credentialPublicKey: [UInt8], + credentialCurrentSignCount: UInt32 + ) throws { + try clientData.verify( + storedChallenge: expectedChallenge, + ceremonyType: .assert, + relyingPartyOrigin: relyingPartyOrigin + ) + + let expectedRelyingPartyIDData = Data(relyingPartyID.utf8) + let expectedRelyingPartyIDHash = SHA256.hash(data: expectedRelyingPartyIDData) + guard expectedRelyingPartyIDHash == authenticatorData.relyingPartyIDHash else { + throw WebAuthnError.relyingPartyIDHashDoesNotMatch + } + + guard authenticatorData.flags.userPresent else { throw WebAuthnError.userPresentFlagNotSet } + if requireUserVerification { + guard authenticatorData.flags.userVerified else { throw WebAuthnError.userVerifiedFlagNotSet } + } + + if authenticatorData.counter > 0 || credentialCurrentSignCount > 0 { + guard authenticatorData.counter > credentialCurrentSignCount else { + // This is a signal that the authenticator may be cloned, i.e. at least two copies of the credential + // private key may exist and are being used in parallel. + throw WebAuthnError.potentialReplayAttack + } + } + + let clientDataHash = SHA256.hash(data: rawClientData) + let signatureBase = rawAuthenticatorData + clientDataHash + + let credentialPublicKey = try CredentialPublicKey(publicKeyBytes: credentialPublicKey) + guard let signatureData = signature.urlDecoded.decoded else { throw WebAuthnError.invalidSignature } + try credentialPublicKey.verify(signature: signatureData, data: signatureBase) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/PublicKeyCredentialRequestOptions.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/PublicKeyCredentialRequestOptions.swift new file mode 100644 index 0000000..1da14c4 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Authentication/PublicKeyCredentialRequestOptions.swift @@ -0,0 +1,127 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// The `PublicKeyCredentialRequestOptions` gets passed to the WebAuthn API (`navigator.credentials.get()`) +/// +/// When encoding using `Encodable`, the byte arrays are encoded as base64url. +/// +/// - SeeAlso: https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options +public struct PublicKeyCredentialRequestOptions: Codable, Sendable { + /// A challenge that the authenticator signs, along with other data, when producing an authentication assertion + public let challenge: URLEncodedBase64 + + /// A time, in seconds, that the caller is willing to wait for the call to complete. This is treated as a + /// hint, and may be overridden by the client. + /// + /// - Note: When encoded, this value is represented in milleseconds as a ``UInt32``. + /// See https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options + public let timeout: Duration? + + /// The ID of the Relying Party making the request. + /// + /// This is configured on ``WebAuthnManager`` before its ``WebAuthnManager/beginAuthentication(timeout:allowCredentials:userVerification:)`` method is called. + /// - Note: When encoded, this field appears as `rpId` to match the expectations of `navigator.credentials.get()`. + public let relyingParty: PublicKeyCredentialRelyingPartyEntity + + /// Optionally used by the client to find authenticators eligible for this authentication ceremony. + public let allowCredentials: [PublicKeyCredentialDescriptor]? + + /// Specifies whether the user should be verified during the authentication ceremony. + public let userVerification: UserVerificationRequirement? + + /// Additional parameters requesting additional processing by the client and authenticator. + //public let extensions: [String: Any]? + + private enum CodingKeys: String, CodingKey { + case challenge + case timeout + case rpID = "rpId" + case allowCredentials + case userVerification + } + + init(challenge: URLEncodedBase64, + timeout: Duration? = nil, + relyingParty: PublicKeyCredentialRelyingPartyEntity, + allowCredentials: [PublicKeyCredentialDescriptor]? = nil, + userVerification: UserVerificationRequirement? = nil) { + self.challenge = challenge + self.timeout = timeout + self.relyingParty = relyingParty + self.allowCredentials = allowCredentials + self.userVerification = userVerification + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let challengeStr = try container.decode(String.self, forKey: .challenge) + challenge = URLEncodedBase64(challengeStr) + + if let timeoutDouble = try container.decodeIfPresent(Double.self, forKey: .timeout) { + timeout = Duration.milliseconds(timeoutDouble) + } else { + timeout = nil + } + + let rpID = try container.decode(String.self, forKey: .rpID) + relyingParty = .init(id: rpID, name: "") + + allowCredentials = try container.decodeIfPresent([PublicKeyCredentialDescriptor].self, forKey: .allowCredentials) + userVerification = try container.decodeIfPresent(UserVerificationRequirement.self, forKey: .userVerification) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(challenge.asString(), forKey: .challenge) + try container.encodeIfPresent(timeout?.milliseconds, forKey: .timeout) + try container.encodeIfPresent(relyingParty.id, forKey: .rpID) + try container.encodeIfPresent(allowCredentials, forKey: .allowCredentials) + try container.encodeIfPresent(userVerification, forKey: .userVerification) + } +} + +/// The Relying Party may require user verification for some of its operations but not for others, and may use this +/// type to express its needs. +public enum UserVerificationRequirement: String, Codable, Sendable { + /// The Relying Party requires user verification for the operation and will fail the overall ceremony if the + /// user wasn't verified. + case required + /// The Relying Party prefers user verification for the operation if possible, but will not fail the operation. + case preferred + /// The Relying Party does not want user verification employed during the operation (e.g., in the interest of + /// minimizing disruption to the user interaction flow). + case discouraged +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorAttachment.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorAttachment.swift new file mode 100644 index 0000000..216a4f3 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorAttachment.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2024 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An authenticators' attachment modalities. +/// +/// Relying Parties use this to express a preferred authenticator attachment modality when registering a credential, and clients use this to report the authenticator attachment modality used to complete a registration or authentication ceremony. +/// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.4.5. Authenticator Attachment Enumeration (enum AuthenticatorAttachment)](https://w3c.github.io/webauthn/#enum-attachment) +/// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.2.1. Authenticator Attachment Modality](https://w3c.github.io/webauthn/#sctn-authenticator-attachment-modality) +/// +public struct AuthenticatorAttachment: UnreferencedStringEnumeration, Sendable { + public var rawValue: String + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + /// A platform authenticator is attached using a client device-specific transport, called platform attachment, and is usually not removable from the client device. A public key credential bound to a platform authenticator is called a platform credential. + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.2.1. Authenticator Attachment Modality](https://w3c.github.io/webauthn/#platform-attachment) + public static let platform: Self = "platform" + + /// A roaming authenticator is attached using cross-platform transports, called cross-platform attachment. Authenticators of this class are removable from, and can "roam" between, client devices. A public key credential bound to a roaming authenticator is called a roaming credential. + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.2.1. Authenticator Attachment Modality](https://w3c.github.io/webauthn/#cross-platform-attachment) + public static let crossPlatform: Self = "cross-platform" +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorAttestationGloballyUniqueID.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorAttestationGloballyUniqueID.swift new file mode 100644 index 0000000..0a14645 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorAttestationGloballyUniqueID.swift @@ -0,0 +1,94 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2024 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// A globally unique ID identifying an authenticator. +/// +/// Each authenticator has an Authenticator Attestation Globally Unique Identifier or **AAGUID**, which is a 128-bit identifier indicating the type (e.g. make and model) of the authenticator. The AAGUID MUST be chosen by its maker to be identical across all substantially identical authenticators made by that maker, and different (with high probability) from the AAGUIDs of all other types of authenticators. The AAGUID for a given type of authenticator SHOULD be randomly generated to ensure this. +/// +/// The Relying Party MAY use the AAGUID to infer certain properties of the authenticator, such as certification level and strength of key protection, using information from other sources. The Relying Party MAY use the AAGUID to attempt to identify the maker of the authenticator without requesting and verifying attestation, but the AAGUID is not provably authentic without attestation. +/// - SeeAlso: [WebAuthn Leven 3 Editor's Draft §6. WebAuthn Authenticator Model](https://w3c.github.io/webauthn/#aaguid) +public struct AuthenticatorAttestationGloballyUniqueID: Hashable, Sendable { + /// The underlying UUID for the authenticator. + public let id: UUID + + /// Initialize an AAGUID with a UUID. + public init(uuid: UUID) { + self.id = uuid + } + + /// Initialize an AAGUID with a byte sequence. + /// + /// This sequence must be of length ``AuthenticatorAttestationGloballyUniqueID/size``. + @inlinable + public init?(bytes: some BidirectionalCollection) { + let uuidSize = MemoryLayout.size + assert(uuidSize == Self.size, "Size of uuid_t (\(uuidSize)) does not match Self.size (\(Self.size))!") + guard bytes.count == uuidSize else { return nil } + self.init(uuid: UUID(uuid: bytes.casting())) + } + + /// Initialize an AAGUID with a string-based UUID. + @inlinable + public init?(uuidString: String) { + guard let uuid = UUID(uuidString: uuidString) + else { return nil } + + self.init(uuid: uuid) + } + + /// Access the AAGUID as an encoded byte sequence. + @inlinable + public var bytes: [UInt8] { withUnsafeBytes(of: id) { Array($0) } } + + /// The identifier of an anonymized authenticator, set to a byte sequence of 16 zeros. + public static let anonymous = AuthenticatorAttestationGloballyUniqueID(bytes: Array(repeating: 0, count: Self.size))! + + /// The byte length of an encoded identifer. + public static let size: Int = 16 +} + +/// A shorthand for an ``AuthenticatorAttestationGloballyUniqueID`` +typealias AAGUID = AuthenticatorAttestationGloballyUniqueID + +extension AuthenticatorAttestationGloballyUniqueID: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + id = try container.decode(UUID.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(id) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorData.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorData.swift new file mode 100644 index 0000000..77cae30 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorData.swift @@ -0,0 +1,173 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import CryptoKit +import SwiftCBOR + +/// Data created and/ or used by the authenticator during authentication/ registration. +/// The data contains, for example, whether a user was present or verified. +struct AuthenticatorData: Equatable, Sendable { + let relyingPartyIDHash: [UInt8] + let flags: AuthenticatorFlags + let counter: UInt32 + /// For attestation signatures this value will be set. For assertion signatures not. + let attestedData: AttestedCredentialData? + let extData: [UInt8]? +} + +extension AuthenticatorData { + init(bytes: [UInt8]) throws { + let minAuthDataLength = 37 + guard bytes.count >= minAuthDataLength else { + throw WebAuthnError.authDataTooShort + } + + let relyingPartyIDHash = Array(bytes[..<32]) + let flags = AuthenticatorFlags(bytes[32]) + let counter = UInt32(bigEndianBytes: bytes[33..<37]) + + var remainingCount = bytes.count - minAuthDataLength + + // If the BE bit of the flags in authData is not set, verify that + // the BS bit is not set. + // Trying to keep the business logic consistent with Android, + // but actually executing the test material causes the check to fail. + //guard flags.isBackupEligible, !flags.isCurrentlyBackedUp else { + // throw WebAuthnError.authenticatorFlagsCheckFailed + //} + + var attestedCredentialData: AttestedCredentialData? + // For attestation signatures, the authenticator MUST set the AT flag and include the attestedCredentialData. + if flags.attestedCredentialData { + let minAttestedAuthLength = 37 + AAGUID.size + 2 + guard bytes.count > minAttestedAuthLength else { + throw WebAuthnError.attestedCredentialDataMissing + } + let (data, length) = try Self.parseAttestedData(bytes) + attestedCredentialData = data + remainingCount -= length + // For assertion signatures, the AT flag MUST NOT be set and the attestedCredentialData MUST NOT be included. + } else { + if !flags.extensionDataIncluded && bytes.count != minAuthDataLength { + throw WebAuthnError.attestedCredentialFlagNotSet + } + } + + var extensionData: [UInt8]? + if flags.extensionDataIncluded { + guard remainingCount != 0 else { + throw WebAuthnError.extensionDataMissing + } + extensionData = Array(bytes[(bytes.count - remainingCount)...]) + remainingCount -= extensionData!.count + } + + guard remainingCount == 0 else { + throw WebAuthnError.leftOverBytesInAuthenticatorData + } + + self.relyingPartyIDHash = relyingPartyIDHash + self.flags = flags + self.counter = counter + self.attestedData = attestedCredentialData + self.extData = extensionData + + } + + /// Parse and return the attested credential data and its length. + /// + /// This is assumed to take place after the first 37 bytes of `data`, which is always of fixed size. + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.5.1. Attested Credential Data]( https://w3c.github.io/webauthn/#sctn-attested-credential-data) + private static func parseAttestedData(_ data: [UInt8]) throws -> (AttestedCredentialData, Int) { + /// **aaguid** (16): The AAGUID of the authenticator. + guard let aaguid = AAGUID(bytes: data[37..<(37 + AAGUID.size)]) // Bytes [37-52] + else { throw WebAuthnError.attestedCredentialDataMissing } + + /// **credentialIdLength** (2): Byte length L of credentialId, 16-bit unsigned big-endian integer. Value MUST be ≤ 1023. + let idLengthBytes = data[53..<55] // Length is 2 bytes + let idLengthData = Data(idLengthBytes) + let idLength = UInt16(bigEndianBytes: idLengthData) + + guard idLength <= 1023 + else { throw WebAuthnError.credentialIDTooLong } + + let credentialIDEndIndex = Int(idLength) + 55 + guard data.count >= credentialIDEndIndex + else { throw WebAuthnError.credentialIDTooShort } + + /// **credentialId** (L): Credential ID + let credentialID = data[55.. + + init(_ slice: ArraySlice) { + self.slice = slice + } + + /// The remaining bytes in the original data buffer. + var remainingBytes: Int { slice.count } + + func popByte() throws -> UInt8 { + if slice.count < 1 { throw CBORError.unfinishedSequence } + return slice.removeFirst() + } + + func popBytes(_ n: Int) throws -> ArraySlice { + if slice.count < n { throw CBORError.unfinishedSequence } + let result = slice.prefix(n) + slice = slice.dropFirst(n) + return result + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorFlags.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorFlags.swift new file mode 100644 index 0000000..a103573 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/AuthenticatorFlags.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +struct AuthenticatorFlags: Equatable, Sendable { + + /** + Taken from https://w3c.github.io/webauthn/#sctn-authenticator-data + Bit 0: User Present Result + Bit 1: Reserved for future use + Bit 2: User Verified Result + Bits 3-5: Reserved for future use + Bit 6: Attested credential data included + Bit 7: Extension data include + */ + + enum Bit: UInt8 { + case userPresent = 0 + case userVerified = 2 + case backupEligible = 3 + case backupState = 4 + case attestedCredentialDataIncluded = 6 + case extensionDataIncluded = 7 + } + + let userPresent: Bool + let userVerified: Bool + let isBackupEligible: Bool + let isCurrentlyBackedUp: Bool + let attestedCredentialData: Bool + let extensionDataIncluded: Bool + + static func isFlagSet(on byte: UInt8, at position: Bit) -> Bool { + (byte & (1 << position.rawValue)) != 0 + } +} + +extension AuthenticatorFlags { + init(_ byte: UInt8) { + userPresent = Self.isFlagSet(on: byte, at: .userPresent) + userVerified = Self.isFlagSet(on: byte, at: .userVerified) + isBackupEligible = Self.isFlagSet(on: byte, at: .backupEligible) + isCurrentlyBackedUp = Self.isFlagSet(on: byte, at: .backupState) + attestedCredentialData = Self.isFlagSet(on: byte, at: .attestedCredentialDataIncluded) + extensionDataIncluded = Self.isFlagSet(on: byte, at: .extensionDataIncluded) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEAlgorithmIdentifier.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEAlgorithmIdentifier.swift new file mode 100644 index 0000000..b3d67dc --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEAlgorithmIdentifier.swift @@ -0,0 +1,68 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import CryptoKit + +/// COSEAlgorithmIdentifier From §5.10.5. A number identifying a cryptographic algorithm. The algorithm +/// identifiers SHOULD be values registered in the IANA COSE Algorithms registry +/// [https://www.w3.org/TR/webauthn/#biblio-iana-cose-algs-reg], for instance, -7 for "ES256" and -257 for "RS256". +public enum COSEAlgorithmIdentifier: Int, RawRepresentable, CaseIterable, Encodable, Sendable { + + case unknown = 0 + + /// AlgES256 ECDSA with SHA-256 + case algES256 = -7 + /// AlgES384 ECDSA with SHA-384 + case algES384 = -35 + /// AlgES512 ECDSA with SHA-512 + case algES512 = -36 + + /// AlgRS1 RSASSA-PKCS1-v1_5 with SHA-1 + case algRS1 = -65535 + /// AlgRS256 RSASSA-PKCS1-v1_5 with SHA-256 + case algRS256 = -257 + /// AlgRS384 RSASSA-PKCS1-v1_5 with SHA-384 + case algRS384 = -258 + /// AlgRS512 RSASSA-PKCS1-v1_5 with SHA-512 + case algRS512 = -259 + + /// AlgPS256 RSASSA-PSS with SHA-256 + case algPS256 = -37 + /// AlgPS384 RSASSA-PSS with SHA-384 + case algPS384 = -38 + /// AlgPS512 RSASSA-PSS with SHA-512 + case algPS512 = -39 + + // AlgEdDSA EdDSA + case algEdDSA = -8 +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSECurve.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSECurve.swift new file mode 100644 index 0000000..a70392d --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSECurve.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2023 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +enum COSECurve: UInt64, Sendable { + /// EC2, NIST P-256 also known as secp256r1 + case p256 = 1 + /// EC2, NIST P-384 also known as secp384r1 + case p384 = 2 + /// EC2, NIST P-521 also known as secp521r1 + case p521 = 3 + /// OKP, Ed25519 for use w/ EdDSA only + case ed25519 = 6 +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEKey.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEKey.swift new file mode 100644 index 0000000..797871b --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEKey.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2023 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftCBOR + +enum COSEKey: Sendable { + // swiftlint:disable identifier_name + case kty + case alg + + // EC2, OKP + case crv + case x + + // EC2 + case y + + // RSA + case n + case e + // swiftlint:enable identifier_name + + var cbor: CBOR { + var value: Int + switch self { + case .kty: + value = 1 + case .alg: + value = 3 + case .crv: + value = -1 + case .x: + value = -2 + case .y: + value = -3 + case .n: + value = -1 + case .e: + value = -2 + } + if value < 0 { + return .negativeInt(UInt64(abs(-1 - value))) + } else { + return .unsignedInt(UInt64(value)) + } + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEKeyType.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEKeyType.swift new file mode 100644 index 0000000..509ea07 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/COSE/COSEKeyType.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// The Key Type derived from the IANA COSE AuthData +enum COSEKeyType: UInt64, RawRepresentable, Sendable { + /// OctetKey is an Octet Key + case octetKey = 1 + /// EllipticKey is an Elliptic Curve Public Key + case ellipticKey = 2 + /// RSAKey is an RSA Public Key + case rsaKey = 3 +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CollectedClientData.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CollectedClientData.swift new file mode 100644 index 0000000..f603b8c --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CollectedClientData.swift @@ -0,0 +1,73 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// A parsed version of the `clientDataJSON` received from the authenticator. The `clientDataJSON` is a +/// representation of the options we passed to the WebAuthn API (`.get()`/ `.create()`). +public struct CollectedClientData: Codable, Hashable, Sendable { + enum CollectedClientDataVerifyError: Error { + case ceremonyTypeDoesNotMatch + case challengeDoesNotMatch + case originDoesNotMatch + } + + public enum CeremonyType: String, Codable, Sendable { + case create = "webauthn.create" + case assert = "webauthn.get" + } + + /// Contains the string "webauthn.create" when creating new credentials, + /// and "webauthn.get" when getting an assertion from an existing credential + public let type: CeremonyType + /// The challenge that was provided by the Relying Party + public let challenge: URLEncodedBase64 + public let origin: String + + func verify(storedChallenge: [UInt8], + ceremonyType: CeremonyType, + relyingPartyOrigin: String? = nil) throws { + guard type == ceremonyType else { + throw CollectedClientDataVerifyError.ceremonyTypeDoesNotMatch + } + + guard challenge == storedChallenge.base64URLEncodedString() else { + throw CollectedClientDataVerifyError.challengeDoesNotMatch + } + + guard let relyingPartyOrigin else { return } + + guard origin == relyingPartyOrigin else { + throw CollectedClientDataVerifyError.originDoesNotMatch + } + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CredentialPublicKey.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CredentialPublicKey.swift new file mode 100644 index 0000000..441a679 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CredentialPublicKey.swift @@ -0,0 +1,254 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import CryptoKit +import SwiftCBOR + +protocol PublicKey: Sendable { + var algorithm: COSEAlgorithmIdentifier { get } + /// Verify a signature was signed with the private key corresponding to the public key. + func verify(signature: some DataProtocol, data: some DataProtocol) throws +} + +enum CredentialPublicKey: Sendable { + case okp(OKPPublicKey) + case ec2(EC2PublicKey) + case rsa(RSAPublicKeyData) + + var key: PublicKey { + switch self { + case let .okp(key): + return key + case let .ec2(key): + return key + case let .rsa(key): + return key + } + } + + init(publicKeyBytes: [UInt8]) throws { + guard let publicKeyObject = try CBOR.decode(publicKeyBytes, options: CBOROptions(maximumDepth: 16)) else { + throw WebAuthnError.badPublicKeyBytes + } + + // A leading 0x04 means we got a public key from an old U2F security key. + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-registry-v2.0-id-20180227.html#public-key-representation-formats + guard publicKeyBytes[0] != 0x04 else { + self = .ec2(EC2PublicKey( + algorithm: .algES256, + curve: .p256, + xCoordinate: Array(publicKeyBytes[1...32]), + yCoordinate: Array(publicKeyBytes[33...64]) + )) + return + } + + guard let keyTypeRaw = publicKeyObject[COSEKey.kty.cbor], + case let .unsignedInt(keyTypeInt) = keyTypeRaw, + let keyType = COSEKeyType(rawValue: keyTypeInt) else { + throw WebAuthnError.invalidKeyType + } + + guard let algorithmRaw = publicKeyObject[COSEKey.alg.cbor], + case let .negativeInt(algorithmNegative) = algorithmRaw else { + throw WebAuthnError.invalidAlgorithm + } + // https://github.com/unrelentingtech/SwiftCBOR#swiftcbor + // Negative integers are decoded as NegativeInt(UInt), where the actual number is -1 - i + guard let algorithm = COSEAlgorithmIdentifier(rawValue: -1 - Int(algorithmNegative)) else { + throw WebAuthnError.unsupportedCOSEAlgorithm + } + + // Currently we only support elliptic curve algorithms + switch keyType { + case .ellipticKey: + self = try .ec2(EC2PublicKey(publicKeyObject: publicKeyObject, algorithm: algorithm)) + case .rsaKey: + throw WebAuthnError.unsupported + // self = try .rsa(RSAPublicKeyData(publicKeyObject: publicKeyObject, algorithm: algorithm)) + case .octetKey: + throw WebAuthnError.unsupported + // self = try .okp(OKPPublicKey(publicKeyObject: publicKeyObject, algorithm: algorithm)) + } + } + + /// Verify a signature was signed with the private key corresponding to the provided public key. + func verify(signature: some DataProtocol, data: some DataProtocol) throws { + try key.verify(signature: signature, data: data) + } +} + +struct EC2PublicKey: PublicKey, Sendable { + let algorithm: COSEAlgorithmIdentifier + /// The curve on which we derive the signature from. + let curve: COSECurve + /// A byte string 32 bytes in length that holds the x coordinate of the key. + let xCoordinate: [UInt8] + /// A byte string 32 bytes in length that holds the y coordinate of the key. + let yCoordinate: [UInt8] + + var rawRepresentation: [UInt8] { xCoordinate + yCoordinate } + + init(algorithm: COSEAlgorithmIdentifier, curve: COSECurve, xCoordinate: [UInt8], yCoordinate: [UInt8]) { + self.algorithm = algorithm + self.curve = curve + self.xCoordinate = xCoordinate + self.yCoordinate = yCoordinate + } + + init(publicKeyObject: CBOR, algorithm: COSEAlgorithmIdentifier) throws { + self.algorithm = algorithm + + // Curve is key -1 - or -0 for SwiftCBOR + // X Coordinate is key -2, or NegativeInt 1 for SwiftCBOR + // Y Coordinate is key -3, or NegativeInt 2 for SwiftCBOR + guard let curveRaw = publicKeyObject[COSEKey.crv.cbor], + case let .unsignedInt(curve) = curveRaw, + let coseCurve = COSECurve(rawValue: curve) else { + throw WebAuthnError.invalidCurve + } + self.curve = coseCurve + + guard let xCoordRaw = publicKeyObject[COSEKey.x.cbor], + case let .byteString(xCoordinateBytes) = xCoordRaw else { + throw WebAuthnError.invalidXCoordinate + } + xCoordinate = xCoordinateBytes + guard let yCoordRaw = publicKeyObject[COSEKey.y.cbor], + case let .byteString(yCoordinateBytes) = yCoordRaw else { + throw WebAuthnError.invalidYCoordinate + } + yCoordinate = yCoordinateBytes + } + + func verify(signature: some DataProtocol, data: some DataProtocol) throws { + switch algorithm { + case .algES256: + let ecdsaSignature = try P256.Signing.ECDSASignature(derRepresentation: signature) + guard try P256.Signing.PublicKey(rawRepresentation: rawRepresentation) + .isValidSignature(ecdsaSignature, for: data) else { + throw WebAuthnError.invalidSignature + } + case .algES384: + let ecdsaSignature = try P384.Signing.ECDSASignature(derRepresentation: signature) + guard try P384.Signing.PublicKey(rawRepresentation: rawRepresentation) + .isValidSignature(ecdsaSignature, for: data) else { + throw WebAuthnError.invalidSignature + } + case .algES512: + let ecdsaSignature = try P521.Signing.ECDSASignature(derRepresentation: signature) + guard try P521.Signing.PublicKey(rawRepresentation: rawRepresentation) + .isValidSignature(ecdsaSignature, for: data) else { + throw WebAuthnError.invalidSignature + } + default: + throw WebAuthnError.unsupported + } + } +} + +/// Currently not in use +struct RSAPublicKeyData: PublicKey, Sendable { + let algorithm: COSEAlgorithmIdentifier + // swiftlint:disable:next identifier_name + let n: [UInt8] + // swiftlint:disable:next identifier_name + let e: [UInt8] + + var rawRepresentation: [UInt8] { n + e } + + init(publicKeyObject: CBOR, algorithm: COSEAlgorithmIdentifier) throws { + self.algorithm = algorithm + + guard let nRaw = publicKeyObject[COSEKey.n.cbor], + case let .byteString(nBytes) = nRaw else { + throw WebAuthnError.invalidModulus + } + n = nBytes + + guard let eRaw = publicKeyObject[COSEKey.e.cbor], + case let .byteString(eBytes) = eRaw else { + throw WebAuthnError.invalidExponent + } + e = eBytes + } + + func verify(signature: some DataProtocol, data: some DataProtocol) throws { + throw WebAuthnError.unsupported + // let rsaSignature = _RSA.Signing.RSASignature(derRepresentation: signature) + + // var rsaPadding: _RSA.Signing.Padding + // switch algorithm { + // case .algRS1, .algRS256, .algRS384, .algRS512: + // rsaPadding = .insecurePKCS1v1_5 + // case .algPS256, .algPS384, .algPS512: + // rsaPadding = .PSS + // default: + // throw WebAuthnError.unsupportedCOSEAlgorithmForRSAPublicKey + // } + + // guard try _RSA.Signing.PublicKey(rawRepresentation: rawRepresentation).isValidSignature( + // rsaSignature, + // for: data, + // padding: rsaPadding + // ) else { + // throw WebAuthnError.invalidSignature + // } + } +} + +/// Currently not in use +struct OKPPublicKey: PublicKey, Sendable { + let algorithm: COSEAlgorithmIdentifier + let curve: UInt64 + let xCoordinate: [UInt8] + + init(publicKeyObject: CBOR, algorithm: COSEAlgorithmIdentifier) throws { + self.algorithm = algorithm + // Curve is key -1, or NegativeInt 0 for SwiftCBOR + guard let curveRaw = publicKeyObject[.negativeInt(0)], case let .unsignedInt(curve) = curveRaw else { + throw WebAuthnError.invalidCurve + } + self.curve = curve + // X Coordinate is key -2, or NegativeInt 1 for SwiftCBOR + guard let xCoordRaw = publicKeyObject[.negativeInt(1)], + case let .byteString(xCoordinateBytes) = xCoordRaw else { + throw WebAuthnError.invalidXCoordinate + } + xCoordinate = xCoordinateBytes + } + + func verify(signature: some DataProtocol, data: some DataProtocol) throws { + throw WebAuthnError.unsupported + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CredentialType.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CredentialType.swift new file mode 100644 index 0000000..916e35d --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Models/CredentialType.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2024 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// The type of credential being used. +/// +/// Only ``CredentialType/publicKey`` is supported by WebAuthn. +/// - SeeAlso: [Credential Management Level 1 Editor's Draft §2.1.2. Credential Type Registry](https://w3c.github.io/webappsec-credential-management/#sctn-cred-type-registry) +/// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.1. PublicKeyCredential Interface](https://w3c.github.io/webauthn/#iface-pkcredential) +public struct CredentialType: UnreferencedStringEnumeration, Codable, Sendable { + public var rawValue: String + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + /// A public key credential. + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.1. PublicKeyCredential Interface](https://w3c.github.io/webauthn/#iface-pkcredential) + public static let publicKey: Self = "public-key" +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestationFormat.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestationFormat.swift new file mode 100644 index 0000000..dee1fc4 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestationFormat.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public enum AttestationFormat: Equatable, Sendable { + case apple + case none + case custom(String) + + public var rawValue: String { + switch self { + case .apple: return "apple" + case .none: return "none" + case .custom(let value): return value + } + } + + public init?(rawValue: String) { + switch rawValue { + case "apple": self = .apple + case "none": self = .none + default: self = .custom(rawValue) + } + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestationObject.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestationObject.swift new file mode 100644 index 0000000..591d695 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestationObject.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +@preconcurrency import SwiftCBOR + +/// Contains the cryptographic attestation that a new key pair was created by that authenticator. +public struct AttestationObject: Sendable { + + let authenticatorData: AuthenticatorData + + let rawAuthenticatorData: [UInt8] + + let format: AttestationFormat + + let attestationStatement: CBOR + +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestedCredentialData.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestedCredentialData.swift new file mode 100644 index 0000000..71f4bf6 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AttestedCredentialData.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// Contains the new public key created by the authenticator. +struct AttestedCredentialData: Equatable { + let authenticatorAttestationGUID: AAGUID + let credentialID: [UInt8] + let publicKey: [UInt8] +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AuthenticatorAttestationResponse.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AuthenticatorAttestationResponse.swift new file mode 100644 index 0000000..980b619 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AuthenticatorAttestationResponse.swift @@ -0,0 +1,127 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftCBOR + +/// The response from the authenticator device for the creation of a new public key credential. +public struct AuthenticatorAttestationResponse: AuthenticatorResponse, Encodable, Sendable { + /// JSON-compatible serialization of client data passed to the authenticator by the client in order to generate this credential. + public let clientDataJSON: URLEncodedBase64 + + /// This attribute contains an attestation object, which is opaque to, and cryptographically protected against tampering by, the client. + public let attestationObject: URLEncodedBase64 + + /// The authenticator data structure encodes contextual bindings made by the authenticator. + public var authenticatorData: URLEncodedBase64? = nil + + /// The transports that the authenticator is believed to support, or an empty sequence if the information is unavailable. + public var transports: [String]? = nil + + /// The public key of the credential + public var publicKey: URLEncodedBase64? = nil + + /// The COSEAlgorithmIdentifier for the credential public key + public var publicKeyAlgorithm: Int? = nil + + private enum CodingKeys: String, CodingKey { + case clientDataJSON + case attestationObject + case authenticatorData + case transports + case publicKey + case publicKeyAlgorithm + } + + public init(rawClientDataJSON: [UInt8], rawAttestationObject: [UInt8]) { + self.clientDataJSON = rawClientDataJSON.base64URLEncodedString() + self.attestationObject = rawAttestationObject.base64URLEncodedString() + + guard let parsed = try? ParsedAuthenticatorAttestationResponse( + rawClientDataJSON: rawClientDataJSON, + rawAttestationObject: rawAttestationObject + ) else { return } + + self.authenticatorData = parsed.authenticatorData.base64URLEncodedString() + + if let attestedCredentialData = parsed.attestationObject.authenticatorData.attestedData { + self.publicKey = attestedCredentialData.publicKey.base64URLEncodedString() + + guard let credentialPublicKey = try? CredentialPublicKey(publicKeyBytes: attestedCredentialData.publicKey) else { return } + + self.publicKeyAlgorithm = credentialPublicKey.key.algorithm.rawValue + } + } +} + +/// A parsed version of `AuthenticatorAttestationResponse` +struct ParsedAuthenticatorAttestationResponse { + let clientData: CollectedClientData + let attestationObject: AttestationObject + let authenticatorData: [UInt8] + + init(rawClientDataJSON: [UInt8], rawAttestationObject: [UInt8]) throws { + // assembling clientData + let clientData = try JSONDecoder().decode(CollectedClientData.self, from: Data(rawClientDataJSON)) + self.clientData = clientData + + // assembling attestationObject + let attestationObjectData = Data(rawAttestationObject) + guard let decodedAttestationObject = try? CBOR.decode([UInt8](attestationObjectData), options: CBOROptions(maximumDepth: 16)) else { + throw WebAuthnError.invalidAttestationObject + } + + guard let authData = decodedAttestationObject["authData"], + case let .byteString(authDataBytes) = authData else { + throw WebAuthnError.invalidAuthData + } + + authenticatorData = authDataBytes + + guard let formatCBOR = decodedAttestationObject["fmt"], + case let .utf8String(format) = formatCBOR, + let attestationFormat = AttestationFormat(rawValue: format) else { + throw WebAuthnError.invalidFmt + } + + guard let attestationStatement = decodedAttestationObject["attStmt"] else { + throw WebAuthnError.missingAttStmt + } + + attestationObject = AttestationObject( + authenticatorData: try AuthenticatorData(bytes: authDataBytes), + rawAuthenticatorData: authDataBytes, + format: attestationFormat, + attestationStatement: attestationStatement + ) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AuthenticatorSelectionCriteria.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AuthenticatorSelectionCriteria.swift new file mode 100644 index 0000000..115312f --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/AuthenticatorSelectionCriteria.swift @@ -0,0 +1,26 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct AuthenticatorSelectionCriteria: Codable, Sendable { + public let authenticatorAttachment: AuthenticatorAttachment? + public let residentKey: String + public let requireResidentKey: Bool + public let userVerification: String +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/PublicKeyCredentialCreationOptions.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/PublicKeyCredentialCreationOptions.swift new file mode 100644 index 0000000..43a02a2 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/PublicKeyCredentialCreationOptions.swift @@ -0,0 +1,255 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// The `PublicKeyCredentialCreationOptions` gets passed to the WebAuthn API (`navigator.credentials.create()`) +/// +/// - SeeAlso: https://www.w3.org/TR/webauthn-2/#dictionary-makecredentialoptions +public struct PublicKeyCredentialCreationOptions: Codable, Sendable { + /// A byte array randomly generated by the Relying Party. Should be at least 16 bytes long to ensure sufficient + /// entropy. + /// + /// The Relying Party should store the challenge temporarily until the registration flow is complete. + public let challenge: URLEncodedBase64 + + /// Contains a name and an identifier for the Relying Party responsible for the request + public let relyingParty: PublicKeyCredentialRelyingPartyEntity + + /// A list of key types and signature algorithms the Relying Party supports. Ordered from most preferred to least + /// preferred. + public let publicKeyCredentialParameters: [PublicKeyCredentialParameters] + + /// Contains names and an identifier for the user account performing the registration + public let user: PublicKeyCredentialUserEntity + + /// This member is intended for use by Relying Parties that wish to select the appropriate authenticators to + /// participate in the create() operation. + public let authenticatorSelectionCriteria: AuthenticatorSelectionCriteria? + + /// A time, in seconds, that the caller is willing to wait for the call to complete. This is treated as a + /// hint, and may be overridden by the client. + /// + /// - Note: When encoded, this value is represented in milleseconds as a ``UInt32``. + /// See https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options + public let timeout: Duration? + + /// This member is intended for use by Relying Parties that wish to limit the creation of + /// multiple credentials for the same account on a single authenticator. + public let excludeCredentials: [PublicKeyCredentialDescriptor]? + + /// This member is intended for use by Relying Parties that wish to express their preference for attestation conveyance. + public let attestation: String? + + /// This member contains additional parameters requesting additional processing by the client and authenticator. + //public let extensions: [String: Any]? + + private enum CodingKeys: String, CodingKey { + case challenge + case relyingParty = "rp" + case publicKeyCredentialParameters = "pubKeyCredParams" + case user + case authenticatorSelectionCriteria = "authenticatorSelection" + case timeout + case excludeCredentials + case attestation + } + + init(challenge: URLEncodedBase64, + relyingParty: PublicKeyCredentialRelyingPartyEntity, + publicKeyCredentialParameters: [PublicKeyCredentialParameters], + user: PublicKeyCredentialUserEntity, + authenticatorSelectionCriteria: AuthenticatorSelectionCriteria? = nil, + timeout: Duration? = nil, + excludeCredentials: [PublicKeyCredentialDescriptor]? = nil, + attestation: String? = nil) { + self.challenge = challenge + self.relyingParty = relyingParty + self.publicKeyCredentialParameters = publicKeyCredentialParameters + self.user = user + self.authenticatorSelectionCriteria = authenticatorSelectionCriteria + self.timeout = timeout + self.excludeCredentials = excludeCredentials + self.attestation = attestation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let challengeStr = try container.decode(String.self, forKey: .challenge) + challenge = URLEncodedBase64(challengeStr) + + relyingParty = try container.decode(PublicKeyCredentialRelyingPartyEntity.self, forKey: .relyingParty) + publicKeyCredentialParameters = try container.decode([PublicKeyCredentialParameters].self, forKey: .publicKeyCredentialParameters) + user = try container.decode(PublicKeyCredentialUserEntity.self, forKey: .user) + authenticatorSelectionCriteria = try container.decodeIfPresent(AuthenticatorSelectionCriteria.self, forKey: .authenticatorSelectionCriteria) + + if let timeoutDouble = try container.decodeIfPresent(Double.self, forKey: .timeout) { + timeout = Duration.milliseconds(timeoutDouble) + } else { + timeout = nil + } + + excludeCredentials = try container.decodeIfPresent([PublicKeyCredentialDescriptor].self, forKey: .excludeCredentials) + attestation = try container.decodeIfPresent(String.self, forKey: .attestation) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(challenge.asString(), forKey: .challenge) + try container.encode(relyingParty, forKey: .relyingParty) + try container.encode(publicKeyCredentialParameters, forKey: .publicKeyCredentialParameters) + try container.encode(user, forKey: .user) + try container.encodeIfPresent(authenticatorSelectionCriteria, forKey: .authenticatorSelectionCriteria) + try container.encodeIfPresent(timeout?.milliseconds, forKey: .timeout) + try container.encodeIfPresent(excludeCredentials, forKey: .excludeCredentials) + try container.encodeIfPresent(attestation, forKey: .attestation) + } +} + +// MARK: - Credential parameters +/// From §5.3 (https://w3c.github.io/TR/webauthn/#dictionary-credential-params) +public struct PublicKeyCredentialParameters: Codable, Sendable { + /// The type of credential to be created. At the time of writing always ``CredentialType/publicKey``. + public let type: CredentialType + /// The cryptographic signature algorithm with which the newly generated credential will be used, and thus also + /// the type of asymmetric key pair to be generated, e.g., RSA or Elliptic Curve. + public let alg: COSEAlgorithmIdentifier + + private enum CodingKeys: String, CodingKey { + case type + case alg + } + + /// Creates a new `PublicKeyCredentialParameters` instance. + /// + /// - Parameters: + /// - type: The type of credential to be created. At the time of writing always ``CredentialType/publicKey``. + /// - alg: The cryptographic signature algorithm to be used with the newly generated credential. + /// For example RSA or Elliptic Curve. + public init(type: CredentialType = .publicKey, alg: COSEAlgorithmIdentifier) { + self.type = type + self.alg = alg + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let typeStr = try container.decode(String.self, forKey: .type) + type = CredentialType(typeStr) + + let algInt = try container.decode(Int.self, forKey: .alg) + alg = COSEAlgorithmIdentifier(rawValue: algInt) ?? .unknown + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(type.rawValue, forKey: .type) + try container.encode(alg.rawValue, forKey: .alg) + } +} + +extension Array where Element == PublicKeyCredentialParameters { + /// A list of `PublicKeyCredentialParameters` Swift WebAuthn currently supports. + public static var supported: [Element] { + COSEAlgorithmIdentifier.allCases.map { + Element.init(type: .publicKey, alg: $0) + } + } +} + +// MARK: - Credential entities + +/// From §5.4.2 (https://www.w3.org/TR/webauthn/#sctn-rp-credential-params). +/// The PublicKeyCredentialRelyingPartyEntity dictionary is used to supply additional Relying Party attributes when +/// creating a new credential. +public struct PublicKeyCredentialRelyingPartyEntity: Codable, Sendable { + /// A unique identifier for the Relying Party entity. + public let id: String + + /// A human-readable identifier for the Relying Party, intended only for display. For example, "ACME Corporation", + /// "Wonderful Widgets, Inc." or "ОАО Примертех". + public let name: String +} + + /// From §5.4.3 (https://www.w3.org/TR/webauthn/#dictionary-user-credential-params) + /// The PublicKeyCredentialUserEntity dictionary is used to supply additional user account attributes when + /// creating a new credential. + /// + /// When encoding using `Encodable`, `id` is base64url encoded. +public struct PublicKeyCredentialUserEntity: Codable, Sendable { + /// Generated by the Relying Party, unique to the user account, and must not contain personally identifying + /// information about the user. + public let id: Data + + /// A human-readable identifier for the user account, intended only for display. It helps the user to + /// distinguish between user accounts with similar `displayName`s. For example, two different user accounts + /// might both have the same `displayName`, "Alex P. Müller", but might have different `name` values "alexm", + /// "alex.mueller@example.com" or "+14255551234". + public let name: String + + /// A human-readable name for the user account, intended only for display. + public let displayName: String + + private enum CodingKeys: String, CodingKey { + case id + case name + case displayName + } + + /// Creates a new ``PublicKeyCredentialUserEntity`` from id, name and displayName + public init(id: Data, name: String, displayName: String) { + self.id = id + self.name = name + self.displayName = displayName + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let idStr = try container.decode(String.self, forKey: .id) + id = Data(URLEncodedBase64(idStr).decodedBytes ?? []) + + name = try container.decode(String.self, forKey: .name) + displayName = try container.decode(String.self, forKey: .displayName) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id.base64URLEncodedString().asString(), forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(displayName, forKey: .displayName) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/PublicKeyCredentialDescriptor.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/PublicKeyCredentialDescriptor.swift new file mode 100644 index 0000000..3c395e2 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/PublicKeyCredentialDescriptor.swift @@ -0,0 +1,83 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Information about a generated credential. +/// +/// When encoding using `Encodable`, `id` is encoded as base64url. +public struct PublicKeyCredentialDescriptor: Codable, Sendable { + /// Defines hints as to how clients might communicate with a particular authenticator in order to obtain an + /// assertion for a specific credential + public enum AuthenticatorTransport: String, Codable, Sendable { + /// Indicates the respective authenticator can be contacted over removable USB. + case usb + /// Indicates the respective authenticator can be contacted over Near Field Communication (NFC). + case nfc + /// Indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low Energy / BLE). + case ble + /// Indicates the respective authenticator can be contacted using a combination of (often separate) + /// data-transport and proximity mechanisms. This supports, for example, authentication on a desktop + /// computer using a smartphone. + case hybrid + /// Indicates the respective authenticator is contacted using a client device-specific transport, i.e., it is + /// a platform authenticator. These authenticators are not removable from the client device. + case `internal` + } + + /// Will always be ``CredentialType/publicKey`` + public let type: CredentialType + + /// The sequence of bytes representing the credential's ID + public let id: String + + /// The types of connections to the client/browser the authenticator supports + public let transports: [AuthenticatorTransport]? + + private enum CodingKeys: String, CodingKey { + case type + case id + case transports + } + + public init(type: CredentialType = .publicKey, + id: String, + transports: [AuthenticatorTransport]? = nil) { + self.type = type + self.id = id + self.transports = transports + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let typeStr = try container.decode(String.self, forKey: .type) + type = CredentialType(typeStr) + + id = try container.decode(String.self, forKey: .id) + transports = try container.decodeIfPresent([AuthenticatorTransport].self, forKey: .transports) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(type, forKey: .type) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(transports, forKey: .transports) + } +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/RegistrationCredential.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/RegistrationCredential.swift new file mode 100644 index 0000000..2e91bde --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/Registration/RegistrationCredential.swift @@ -0,0 +1,108 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift WebAuthn open source project +// +// Copyright (c) 2022 the Swift WebAuthn project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift WebAuthn project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// The unprocessed response received from `navigator.credentials.create()`. +public struct RegistrationCredential: PublicKeyCredential, Codable, Sendable { + /// The credential ID of the newly created credential. + public let id: String + + /// Value will always be ``CredentialType/publicKey`` (for now) + public let type: CredentialType + + /// An authenticators' attachment modalities. + public let authenticatorAttachment: AuthenticatorAttachment? + + /// The raw credential ID of the newly created credential. + public let rawID: URLEncodedBase64 + + /// The attestation response from the authenticator. + /// In fact, it is stored in AuthenticatorAttestationResponse + public let response: AuthenticatorResponse + + /// This is a dictionary containing the client extension output values for zero or more WebAuthn Extensions. + public var clientExtensionResults: AuthenticationExtensionsClientOutputs? = nil + + private enum CodingKeys: String, CodingKey { + case id + case type + case authenticatorAttachment + case rawID + case response + case clientExtensionResults + } + + public init(id: String, + type: CredentialType, + authenticatorAttachment: AuthenticatorAttachment?, + rawID: URLEncodedBase64, + response: AuthenticatorAttestationResponse, + clientExtensionResults: AuthenticationExtensionsClientOutputs? = nil) { + self.id = id + self.type = type + self.authenticatorAttachment = authenticatorAttachment + self.rawID = rawID + self.response = response + self.clientExtensionResults = clientExtensionResults + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + type = try container.decode(CredentialType.self, forKey: .type) + authenticatorAttachment = try container.decode(AuthenticatorAttachment.self, forKey: .authenticatorAttachment) + rawID = try container.decode(URLEncodedBase64.self, forKey: .rawID) + response = try container.decode(AuthenticatorAttestationResponse.self, forKey: .response) + clientExtensionResults = try container.decode(AuthenticationExtensionsClientOutputs.self, forKey: .clientExtensionResults) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(authenticatorAttachment, forKey: .authenticatorAttachment) + try container.encode(rawID.asString(), forKey: .rawID) + try container.encode(response, forKey: .response) + try container.encodeIfPresent(clientExtensionResults, forKey: .clientExtensionResults) + } +} + +public struct AuthenticationExtensionsClientOutputs: Codable, Sendable { + let credProps: CredentialPropertiesOutput? +} + +public struct CredentialPropertiesOutput: Codable, Sendable { + let rk: Bool? +} diff --git a/CircleModularWalletsCore/Sources/Helpers/WebAuthn/WebAuthnHandler.swift b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/WebAuthnHandler.swift new file mode 100644 index 0000000..1c3cf83 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Helpers/WebAuthn/WebAuthnHandler.swift @@ -0,0 +1,221 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AuthenticationServices + +class WebAuthnHandler: NSObject, @unchecked Sendable { + + static let shared = WebAuthnHandler() + + private var authenticationAnchor: ASPresentationAnchor? + private var isPerformingModalReqest = false + private var continuation: CheckedContinuation? + private var webAuthnMode: WebAuthnMode = .register + + // In fact, this function returns the RegistrationCredential type + func signUpWith( + anchor: ASPresentationAnchor? = nil, + option: PublicKeyCredentialCreationOptions + ) async throws -> PublicKeyCredential { + return try await withCheckedThrowingContinuation { continuation in + self.authenticationAnchor = anchor + + let rpId = option.relyingParty.id + let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId) + + // Fetch the challenge from the server. The challenge needs to be unique for each request. + // The userID is the identifier for the user's account. + let challenge = Data(option.challenge.decodedBytes ?? []) + let userID = option.user.id + let userName = option.user.name + + let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(challenge: challenge, name: userName, userID: userID) + registrationRequest.displayName = option.user.displayName + + let authController = ASAuthorizationController(authorizationRequests: [ registrationRequest ] ) + + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() + isPerformingModalReqest = true + + self.continuation = continuation + self.webAuthnMode = .register + } + } + + // In fact, this function returns the AuthenticationCredential type + func signInWith( + anchor: ASPresentationAnchor? = nil, + option: PublicKeyCredentialRequestOptions + ) async throws -> PublicKeyCredential { + return try await withCheckedThrowingContinuation { continuation in + self.authenticationAnchor = anchor + + let rpId = option.relyingParty.id + let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId) + + // Fetch the challenge from the server. The challenge needs to be unique for each request. + let challenge = Data(option.challenge.decodedBytes ?? []) + + let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge) + + if let allowCredentials = option.allowCredentials, !allowCredentials.isEmpty { + let credentialDescriptors = allowCredentials.map { + let credentialID = URLEncodedBase64($0.id).decodedBytes + return ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: Data(credentialID ?? [])) + } + assertionRequest.allowedCredentials = credentialDescriptors + } + + if let userVerification = option.userVerification { + assertionRequest.userVerificationPreference = .init(userVerification.rawValue) + } + + // Also allow the user to use a saved password, if they have one. + let passwordCredentialProvider = ASAuthorizationPasswordProvider() + let passwordRequest = passwordCredentialProvider.createRequest() + + // Pass in any mix of supported sign-in request types. + let authController = ASAuthorizationController(authorizationRequests: [ assertionRequest, passwordRequest ] ) + authController.delegate = self + authController.presentationContextProvider = self + + // If credentials are available, presents a modal sign-in sheet. + // If there are no locally saved credentials, the system presents a QR code to allow signing in with a + // passkey from a nearby device. + authController.performRequests() + isPerformingModalReqest = true + + self.continuation = continuation + self.webAuthnMode = .login + } + } +} + +extension WebAuthnHandler: ASAuthorizationControllerDelegate { + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + + switch authorization.credential { + case let asCredentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration: + logger.webAuthn.info("A new passkey was registered: \(asCredentialRegistration)") + + // Verify the attestationObject and clientDataJSON with your service. + // The attestationObject contains the user's new public key to store and use for subsequent sign-ins. + let attestationObjectData = asCredentialRegistration.rawAttestationObject + let clientDataJSON = asCredentialRegistration.rawClientDataJSON + + let credential = RegistrationCredential( + id: asCredentialRegistration.credentialID.base64URLEncodedString().asString(), + type: CredentialType.publicKey, + authenticatorAttachment: .platform, + rawID: asCredentialRegistration.credentialID.base64URLEncodedString(), + response: AuthenticatorAttestationResponse( + rawClientDataJSON: clientDataJSON.bytes, + rawAttestationObject: attestationObjectData?.bytes ?? [] + ) + ) + logger.webAuthn.debug("RegistrationCredential:") + print(credential) + + continuation?.resume(returning: credential) + continuation = nil + + case let asCredentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion: + logger.webAuthn.log("A passkey was used to sign in: \(asCredentialAssertion)") + + // Verify the below signature and clientDataJSON with your service for the given userID. + let signature = asCredentialAssertion.signature + let clientDataJSON = asCredentialAssertion.rawClientDataJSON + let authenticatorData = asCredentialAssertion.rawAuthenticatorData + + let credential = AuthenticationCredential( + id: asCredentialAssertion.credentialID.base64URLEncodedString().asString(), + type: CredentialType.publicKey, + authenticatorAttachment: .platform, + rawID: asCredentialAssertion.credentialID.base64URLEncodedString(), + response: AuthenticatorAssertionResponse( + clientDataJSON: clientDataJSON.bytes.base64URLEncodedString(), + authenticatorData: (authenticatorData ?? .init() ).bytes.base64URLEncodedString(), + signature: (signature ?? .init()).bytes.base64URLEncodedString(), + userHandle: asCredentialAssertion.userID.base64URLEncodedString() + ) + ) + logger.webAuthn.debug("AuthenticationCredential:") + print(credential) + + continuation?.resume(returning: credential) + continuation = nil + + default: + logger.webAuthn.notice("Received unknown authorization type.") + + let error: WebAuthnCredentialError + switch webAuthnMode { + case .register: + error = .registerUnknownAuthType + case .login: + error = .requestUnknownAuthType + } + continuation?.resume(throwing: error) + continuation = nil + } + + isPerformingModalReqest = false + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + if let authorizationError = error as? ASAuthorizationError { + if authorizationError.code == .canceled { + // Either the system doesn't find any credentials and the request ends silently, or the user cancels the request. + // This is a good time to show a traditional login form, or ask the user to create an account. + logger.webAuthn.notice("Request canceled.") + } else { + // Another ASAuthorization error. + // Note: The userInfo dictionary contains useful information. + logger.webAuthn.error("Error: \((error as NSError).userInfo)") + } + } else { + isPerformingModalReqest = false + logger.webAuthn.error("Unexpected authorization error: \(error.localizedDescription)") + } + + let shortErrorMessage: String + switch webAuthnMode { + case .register: + shortErrorMessage = "WebAuthnCredential registration failed" + case .login: + shortErrorMessage = "WebAuthnCredential request failed" + } + + let err = BaseError(shortMessage: shortErrorMessage, + args: .init(cause: error, name: String(describing: error))) + continuation?.resume(throwing: err) + continuation = nil + + isPerformingModalReqest = false + } +} + +extension WebAuthnHandler: ASAuthorizationControllerPresentationContextProviding { + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return authenticationAnchor ?? ASPresentationAnchor() + } +} diff --git a/CircleModularWalletsCore/Sources/Models/Block.swift b/CircleModularWalletsCore/Sources/Models/Block.swift new file mode 100644 index 0000000..04ff07a --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/Block.swift @@ -0,0 +1,116 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt +import Web3Core + +public struct Block { + + public var number: BigUInt // MARK: This is optional in web3js, but required in Ethereum JSON-RPC + public var hash: Data // MARK: This is optional in web3js, but required in Ethereum JSON-RPC + public var parentHash: Data + public var nonce: Data? // MARK: This is optional in web3js but required in Ethereum JSON-RPC + public var sha3Uncles: Data + public var logsBloom: EthereumBloomFilter? // MARK: This is optional in web3js but required in Ethereum JSON-RPC + public var transactionsRoot: Data + public var stateRoot: Data + public var receiptsRoot: Data + public var miner: EthereumAddress? // MARK: This is NOT optional in web3js + public var difficulty: BigUInt + public var totalDifficulty: BigUInt? + public var extraData: Data + public var size: BigUInt + public var gasLimit: BigUInt + public var gasUsed: BigUInt + public var baseFeePerGas: BigUInt? + public var timestamp: Date + public var transactions: [TransactionInBlock] + public var uncles: [Data] + + enum CodingKeys: String, CodingKey { + case number + case hash + case parentHash + case nonce + case sha3Uncles + case logsBloom + case transactionsRoot + case stateRoot + case receiptsRoot + case miner + case difficulty + case totalDifficulty + case extraData + case size + + case gasLimit + case gasUsed + case baseFeePerGas + + case timestamp + case transactions + case uncles + } +} + +extension Block: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.number = try container.decodeHex(BigUInt.self, forKey: .number) + self.hash = try container.decodeHex(Data.self, forKey: .hash) + self.parentHash = try container.decodeHex(Data.self, forKey: .parentHash) + self.nonce = try? container.decodeHex(Data.self, forKey: .nonce) + self.sha3Uncles = try container.decodeHex(Data.self, forKey: .sha3Uncles) + + if let logsBloomData = try? container.decodeHex(Data.self, forKey: .logsBloom) { + self.logsBloom = EthereumBloomFilter(logsBloomData) + } + + self.transactionsRoot = try container.decodeHex(Data.self, forKey: .transactionsRoot) + self.stateRoot = try container.decodeHex(Data.self, forKey: .stateRoot) + self.receiptsRoot = try container.decodeHex(Data.self, forKey: .receiptsRoot) + + if let minerAddress = try? container.decode(String.self, forKey: .miner) { + self.miner = EthereumAddress(minerAddress) + } + + self.difficulty = try container.decodeHex(BigUInt.self, forKey: .difficulty) + self.totalDifficulty = try container.decodeHexIfPresent(BigUInt.self, forKey: .totalDifficulty) + self.extraData = try container.decodeHex(Data.self, forKey: .extraData) + self.size = try container.decodeHex(BigUInt.self, forKey: .size) + self.gasLimit = try container.decodeHex(BigUInt.self, forKey: .gasLimit) + self.gasUsed = try container.decodeHex(BigUInt.self, forKey: .gasUsed) + + // optional, since pre EIP-1559 block haven't such property. + self.baseFeePerGas = try? container.decodeHex(BigUInt.self, forKey: .baseFeePerGas) + + self.timestamp = try container.decodeHex(Date.self, forKey: .timestamp) + + self.transactions = try container.decode([TransactionInBlock].self, forKey: .transactions) + + let unclesStrings = try container.decode([String].self, forKey: .uncles) + self.uncles = try unclesStrings.map { + guard let data = Data.fromHex($0) else { throw Web3Error.dataError } + return data + } + } +} + +extension Block: APIResultType { } diff --git a/CircleModularWalletsCore/Sources/Models/EncodeCallDataArg.swift b/CircleModularWalletsCore/Sources/Models/EncodeCallDataArg.swift new file mode 100644 index 0000000..1babafe --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/EncodeCallDataArg.swift @@ -0,0 +1,53 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +public struct EncodeCallDataArg: Encodable { + + /// In SmartAccount.encodeCalls + /// + /// - to: required + /// - value: optional, default 0 + /// - data: optional, If no data returns “0x” + /// + /// In EncodeFunctionData + /// + /// - abiJson: required + /// - functionName: required + /// - args: optional + + let to: String + let value: BigInt? + let data: String? + let abiJson: String? + let functionName: String? + let args: [AnyEncodable]? + + public init(to: String, value: BigInt? = nil, + data: String? = nil, abiJson: String? = nil, + functionName: String? = nil, args: [AnyEncodable]? = nil) { + self.to = to + self.value = value + self.data = data + self.abiJson = abiJson + self.functionName = functionName + self.args = args + } +} diff --git a/CircleModularWalletsCore/Sources/Models/EntryPoint.swift b/CircleModularWalletsCore/Sources/Models/EntryPoint.swift new file mode 100644 index 0000000..33bb851 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/EntryPoint.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// ref: https://github.com/pimlicolabs/permissionless.js/blob/fb3c71cb38af576d9e0d6d131472ce941b358c9c/packages/permissionless/types/entrypoint.ts#L4 +public enum EntryPoint { + case v07 + + var address: String { + switch self { + case .v07: ENTRYPOINT_V07_ADDRESS + } + } +} diff --git a/CircleModularWalletsCore/Sources/Models/Paymaster.swift b/CircleModularWalletsCore/Sources/Models/Paymaster.swift new file mode 100644 index 0000000..efac55a --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/Paymaster.swift @@ -0,0 +1,40 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public class Paymaster { + + public class True: Paymaster { + let paymasterContext: [String: AnyEncodable]? + + public init(paymasterContext: [String : AnyEncodable]? = nil) { + self.paymasterContext = paymasterContext + } + } + + public class Client: Paymaster { + let client: PaymasterClient + let paymasterContext: [String: AnyEncodable]? + + public init(client: PaymasterClient, paymasterContext: [String : AnyEncodable]? = nil) { + self.client = client + self.paymasterContext = paymasterContext + } + } +} diff --git a/CircleModularWalletsCore/Sources/Models/PublicKeyCredential.swift b/CircleModularWalletsCore/Sources/Models/PublicKeyCredential.swift new file mode 100644 index 0000000..2a43f49 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/PublicKeyCredential.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol PublicKeyCredential: Encodable { + var id: String { get } + var type: CredentialType { get } + var authenticatorAttachment: AuthenticatorAttachment? { get } + var response: AuthenticatorResponse { get } + var clientExtensionResults: AuthenticationExtensionsClientOutputs? { get } +} + +public protocol AuthenticatorResponse: Codable, Sendable { + var clientDataJSON: URLEncodedBase64 { get } +} diff --git a/CircleModularWalletsCore/Sources/Models/SignResult.swift b/CircleModularWalletsCore/Sources/Models/SignResult.swift new file mode 100644 index 0000000..25e4620 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/SignResult.swift @@ -0,0 +1,59 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct SignResult: Decodable { + /// Hex + public let signature: String + public let webAuthn: WebAuthnData + public let raw: AuthenticationCredential +} + +public struct WebAuthnData: Decodable { + /// Hex + public let authenticatorData: String + /// Base64URL decoded + public let clientDataJSON: String + public let challengeIndex: Int + public let typeIndex: Int + public let userVerificationRequired: Bool +} + +extension AuthenticationCredential { + + func toWebAuthnData(userVerification: String) -> WebAuthnData? { + guard let response = response as? AuthenticatorAssertionResponse else { + return nil + } + + let decodedJSON = String(bytes: response.clientDataJSON.decodedBytes ?? [], encoding: .utf8) ?? "" + let authenticatorData = HexUtils.bytesToHex(response.authenticatorData.decodedBytes) + let challengeIndex: Int = decodedJSON.index(of: "\"challenge\"") ?? 0 + let typeIndex: Int = decodedJSON.index(of: "\"type\"") ?? 0 + + return WebAuthnData( + authenticatorData: authenticatorData, + clientDataJSON: decodedJSON, + challengeIndex: challengeIndex, + typeIndex: typeIndex, + userVerificationRequired: userVerification == UserVerificationRequirement.required.rawValue + ) + } +} + diff --git a/CircleModularWalletsCore/Sources/Models/UserOperation.swift b/CircleModularWalletsCore/Sources/Models/UserOperation.swift new file mode 100644 index 0000000..fac61b8 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/UserOperation.swift @@ -0,0 +1,41 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +public protocol Copyable: AnyObject { + func copy() -> Self +} + +public protocol UserOperation: Codable, NSCopying, Copyable { + var sender: String? { get set } + var nonce: BigInt? { get set } + var callData: String? { get set } + var callGasLimit: BigInt? { get set } + var verificationGasLimit: BigInt? { get set } + var preVerificationGas: BigInt? { get set } + var maxPriorityFeePerGas: BigInt? { get set } + var maxFeePerGas: BigInt? { get set } + var signature: String? { get set } +} + +/// Enum to encapsulate different User Operation types +public enum UserOperationType: Codable { + case v07(UserOperationV07) +} diff --git a/CircleModularWalletsCore/Sources/Models/UserOperationV07.swift b/CircleModularWalletsCore/Sources/Models/UserOperationV07.swift new file mode 100644 index 0000000..3a4f2f4 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Models/UserOperationV07.swift @@ -0,0 +1,140 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import BigInt + +// UserOperation v0.7 +public class UserOperationV07: UserOperation { + public var sender: String? + public var nonce: BigInt? + public var callData: String? + public var callGasLimit: BigInt? + public var verificationGasLimit: BigInt? + public var preVerificationGas: BigInt? + public var maxPriorityFeePerGas: BigInt? + public var maxFeePerGas: BigInt? + public var signature: String? + + public var factory: String? + public var factoryData: String? + public var paymaster: String? + public var paymasterVerificationGasLimit: BigInt? + public var paymasterPostOpGasLimit: BigInt? + public var paymasterData: String? + + public init(sender: String? = nil, nonce: BigInt? = nil, callData: String? = nil, callGasLimit: BigInt? = nil, verificationGasLimit: BigInt? = nil, preVerificationGas: BigInt? = nil, maxPriorityFeePerGas: BigInt? = nil, maxFeePerGas: BigInt? = nil, signature: String? = nil, factory: String? = nil, factoryData: String? = nil, paymaster: String? = nil, paymasterVerificationGasLimit: BigInt? = nil, paymasterPostOpGasLimit: BigInt? = nil, paymasterData: String? = nil) { + self.sender = sender + self.nonce = nonce + self.callData = callData + self.callGasLimit = callGasLimit + self.verificationGasLimit = verificationGasLimit + self.preVerificationGas = preVerificationGas + self.maxPriorityFeePerGas = maxPriorityFeePerGas + self.maxFeePerGas = maxFeePerGas + self.signature = signature + self.factory = factory + self.factoryData = factoryData + self.paymaster = paymaster + self.paymasterVerificationGasLimit = paymasterVerificationGasLimit + self.paymasterPostOpGasLimit = paymasterPostOpGasLimit + self.paymasterData = paymasterData + } + + public func copy(with zone: NSZone? = nil) -> Any { + return UserOperationV07( + sender: self.sender, + nonce: self.nonce, + callData: self.callData, + callGasLimit: self.callGasLimit, + verificationGasLimit: self.verificationGasLimit, + preVerificationGas: self.preVerificationGas, + maxPriorityFeePerGas: self.maxPriorityFeePerGas, + maxFeePerGas: self.maxFeePerGas, + signature: self.signature, + factory: self.factory, + factoryData: self.factoryData, + paymaster: self.paymaster, + paymasterVerificationGasLimit: self.paymasterVerificationGasLimit, + paymasterPostOpGasLimit: self.paymasterPostOpGasLimit, + paymasterData: self.paymasterData + ) + } + + public func copy() -> Self { + // swiftlint:disable:next force_cast + return self.copy(with: nil) as! Self + } + + enum CodingKeys: CodingKey { + case sender + case nonce + case callData + case callGasLimit + case verificationGasLimit + case preVerificationGas + case maxPriorityFeePerGas + case maxFeePerGas + case signature + case factory + case factoryData + case paymaster + case paymasterVerificationGasLimit + case paymasterPostOpGasLimit + case paymasterData + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.sender, forKey: .sender) + try container.encodeBigInt(self.nonce, forKey: .nonce) + try container.encodeIfPresent(self.callData, forKey: .callData) + try container.encodeBigInt(self.callGasLimit, forKey: .callGasLimit) + try container.encodeBigInt(self.verificationGasLimit, forKey: .verificationGasLimit) + try container.encodeBigInt(self.preVerificationGas, forKey: .preVerificationGas) + try container.encodeBigInt(self.maxPriorityFeePerGas, forKey: .maxPriorityFeePerGas) + try container.encodeBigInt(self.maxFeePerGas, forKey: .maxFeePerGas) + try container.encodeIfPresent(self.signature, forKey: .signature) + try container.encodeIfPresent(self.factory, forKey: .factory) + try container.encodeIfPresent(self.factoryData, forKey: .factoryData) + try container.encodeIfPresent(self.paymaster, forKey: .paymaster) + try container.encodeBigInt(self.paymasterVerificationGasLimit, forKey: .paymasterVerificationGasLimit) + try container.encodeBigInt(self.paymasterPostOpGasLimit, forKey: .paymasterPostOpGasLimit) + try container.encodeIfPresent(self.paymasterData, forKey: .paymasterData) + } + + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.sender = try container.decodeIfPresent(String.self, forKey: .sender) + self.nonce = try container.decodeToBigInt(forKey: .nonce) + self.callData = try container.decodeIfPresent(String.self, forKey: .callData) + self.callGasLimit = try container.decodeToBigInt(forKey: .callGasLimit) + self.verificationGasLimit = try container.decodeToBigInt(forKey: .verificationGasLimit) + self.preVerificationGas = try container.decodeToBigInt(forKey: .preVerificationGas) + self.maxPriorityFeePerGas = try container.decodeToBigInt(forKey: .maxPriorityFeePerGas) + self.maxFeePerGas = try container.decodeToBigInt(forKey: .maxFeePerGas) + self.signature = try container.decodeIfPresent(String.self, forKey: .signature) + self.factory = try container.decodeIfPresent(String.self, forKey: .factory) + self.factoryData = try container.decodeIfPresent(String.self, forKey: .factoryData) + self.paymaster = try container.decodeIfPresent(String.self, forKey: .paymaster) + self.paymasterVerificationGasLimit = try container.decodeToBigInt(forKey: .paymasterVerificationGasLimit) + self.paymasterPostOpGasLimit = try container.decodeToBigInt(forKey: .paymasterPostOpGasLimit) + self.paymasterData = try container.decodeIfPresent(String.self, forKey: .paymasterData) + } + +} diff --git a/CircleModularWalletsCore/Sources/Transports/Http/HttpRpcClientOptions.swift b/CircleModularWalletsCore/Sources/Transports/Http/HttpRpcClientOptions.swift new file mode 100644 index 0000000..8558736 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Transports/Http/HttpRpcClientOptions.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct HttpRpcClientOptions { + + let headers: [String: String] + + public init(headers: [String : String]) { + self.headers = headers + } +} diff --git a/CircleModularWalletsCore/Sources/Transports/Http/HttpTransport.swift b/CircleModularWalletsCore/Sources/Transports/Http/HttpTransport.swift new file mode 100644 index 0000000..1d514e3 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Transports/Http/HttpTransport.swift @@ -0,0 +1,159 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public func http(url: String, options: HttpRpcClientOptions? = nil) -> HttpTransport { + return .init(url: url, options: options) +} + +public func toPasskeyTransport(clientKey: String, + url: String = CIRCLE_BASE_URL) -> HttpTransport { + let options = HttpRpcClientOptions(headers: ["Authorization" : "Bearer \(clientKey)"]) + return .init(url: url, options: options) +} + +public class HttpTransport: Transport { + + let session: URLSession + let url: String + let options: HttpRpcClientOptions? + + init(url: String, options: HttpRpcClientOptions? = nil) { + self.session = URLSession.shared + self.url = url + self.options = options + } + + public func request(_ rpcRequest: RpcRequest

) async throws -> RpcResponse where P : Encodable, R : Decodable { + do { + let urlRequest = try toUrlRequest(rpcRequest, urlString: self.url) + return try await send(urlRequest) + + } catch let error as BaseError { + throw error + + } catch { + throw BaseError(shortMessage: error.localizedDescription, + args: .init(cause: error, name: String(describing: error))) + } + } +} + +extension HttpTransport { + + func send(_ urlRequest: URLRequest) async throws -> T { + do { + let (data, response) = try await session.data(for: urlRequest) + try processResponse(data: data, response: response) + + if let errorResult = try? decodeData(data: data) as JsonRpcErrorResult { + let rpcRequstError = RpcRequestError(body: urlRequest.httpBody, + error: errorResult.error, + url: url) + let rpcError = ErrorUtils.getRpcError(cause: rpcRequstError) + throw rpcError + } else { + return try decodeData(data: data) as T + } + + } catch let error as HttpError { + var _details: String? + var _cause: Error? + var _statusCode: Int? + + switch error { + case .encodingFailed(let error): + _details = "Encoding Failed." + _cause = error + case .decodingFailed(let error): + _details = "Decoding Failed." + _cause = error + case .unknownError(let statusCode): + _details = "Request failed: \(statusCode)" + _statusCode = statusCode + default: + _details = String(describing: error) + } + throw HttpRequestError(body: urlRequest.httpBody, + cause: _cause, + details: _details, + headers: urlRequest.allHTTPHeaderFields, + status: _statusCode, + url: urlRequest.url?.absoluteString ?? url) + + } catch { + throw HttpRequestError(body: urlRequest.httpBody, + cause: error, + details: "Http request failed.", + headers: urlRequest.allHTTPHeaderFields, + url: urlRequest.url?.absoluteString ?? url) + } + } + + func toUrlRequest(_ request: Encodable, urlString: String) throws -> URLRequest { + guard let url = URL(string: urlString) else { + throw HttpError.badURL + } + + var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + req.httpMethod = "POST" + req.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let appInfos = ["platform=ios", + "version=\(Bundle.SDK.version)", + "bundleid=\(Bundle.main.identifier)"] + req.addValue(appInfos.joined(separator: ";"), forHTTPHeaderField: "X-AppInfo") + + options?.headers.forEach { key, value in + req.addValue(value, forHTTPHeaderField: key) + } + + do { + req.httpBody = try JSONEncoder().encode(request) + } catch let encodingError { + throw HttpError.encodingFailed(encodingError) + } + return req + } + + func processResponse(data: Data, response: URLResponse?) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw HttpError.invalidResponse + } + switch httpResponse.statusCode { + case 200...299: + break + default: throw + HttpError.unknownError(statusCode: httpResponse.statusCode) + } + } + + func decodeData(data: Data) throws -> T { + do { + return try JSONDecoder().decode(T.self, from: data) + + } catch let decodingError { + if !(T.self is JsonRpcErrorResult.Type) { + let dataString = String(data: data, encoding: .utf8) + logger.transport.error("[DecodingError] \(T.self) from content: \(dataString ?? "{}")") + } + throw HttpError.decodingFailed(decodingError) + } + } +} diff --git a/CircleModularWalletsCore/Sources/Transports/Http/ModularTransport.swift b/CircleModularWalletsCore/Sources/Transports/Http/ModularTransport.swift new file mode 100644 index 0000000..f28e3a5 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Transports/Http/ModularTransport.swift @@ -0,0 +1,34 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public func toModularTransport(clientKey: String, + url: String) -> ModularTransport { + return .init(clientKey: clientKey, url: url) +} + +public class ModularTransport: HttpTransport { + + convenience init(clientKey: String, url: String) { + let options = HttpRpcClientOptions(headers: ["Authorization" : "Bearer \(clientKey)"]) + self.init(url: url, options: options) + } +} + +extension ModularTransport: ModularRpcApi { } diff --git a/CircleModularWalletsCore/Sources/Transports/JsonRpc/JsonRpcError.swift b/CircleModularWalletsCore/Sources/Transports/JsonRpc/JsonRpcError.swift new file mode 100644 index 0000000..e9449ba --- /dev/null +++ b/CircleModularWalletsCore/Sources/Transports/JsonRpc/JsonRpcError.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct JsonRpcErrorResult: Decodable { + let id: Int + let jsonrpc: String + let error: JsonRpcError +} + +struct JsonRpcError: Decodable { + let code: Int + let message: String +} diff --git a/CircleModularWalletsCore/Sources/Transports/JsonRpc/JsonRpcReqResp.swift b/CircleModularWalletsCore/Sources/Transports/JsonRpc/JsonRpcReqResp.swift new file mode 100644 index 0000000..f33bd8e --- /dev/null +++ b/CircleModularWalletsCore/Sources/Transports/JsonRpc/JsonRpcReqResp.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct EmptyParam: Encodable {} +let emptyParams = [EmptyParam]() + +public struct RpcRequest: Encodable { + let id: Int = Int(Date().timeIntervalSince1970 * 1000) + let jsonrpc: String = "2.0" + let method: String + let params: P? +} + +public struct RpcResponse: Decodable { + let id: Int + let jsonrpc: String + let result: R +} diff --git a/CircleModularWalletsCore/Sources/Transports/Transport.swift b/CircleModularWalletsCore/Sources/Transports/Transport.swift new file mode 100644 index 0000000..5f3d4a5 --- /dev/null +++ b/CircleModularWalletsCore/Sources/Transports/Transport.swift @@ -0,0 +1,24 @@ +// +// Copyright (c) 2024, Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol Transport { + + func request(_ rpcRequest: RpcRequest

) async throws -> RpcResponse +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..085faa7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + © 2024, Circle Internet Financial, LTD. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..1ea7a66 --- /dev/null +++ b/Package.swift @@ -0,0 +1,53 @@ +// swift-tools-version: 5.7 +// +// Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import PackageDescription + +let package = Package( + name: "CircleModularWalletsCore", + platforms: [ + .iOS(.v16) + ], + products: [ + .library( + name: "CircleModularWalletsCore", + targets: ["CircleModularWalletsCore"]) + ], + dependencies: [ + .package(url: "https://github.com/valpackett/SwiftCBOR.git", .upToNextMinor(from: "0.4.7")), + .package(url: "https://github.com/web3swift-team/web3swift.git", .upToNextMinor(from: "3.2.2")) + ], + targets: [ + .target( + name: "CircleModularWalletsCore", + dependencies: [ + "web3swift", + "SwiftCBOR" + ], + path: "CircleModularWalletsCore/Sources", + resources: [ + .copy("../Resources/PrivacyInfo.xcprivacy") + ], + cSettings: [ + .define("BUILD_LIBRARY_FOR_DISTRIBUTION", to: "YES") + ] + ) + ], + swiftLanguageVersions: [.version("6"), .v5] +) diff --git a/README.md b/README.md index 67e764a..9f1f63a 100644 --- a/README.md +++ b/README.md @@ -1 +1,83 @@ -# modularwallets-ios-sdk \ No newline at end of file +# modularwallets-ios-sdk + +This is **CircleModularWalletsSDK** repo in iOS. + +## Installation + +### Swift Package Manager + +The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. + +Once you have your Swift package set up, adding CircleModularWalletsCore as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift` or the Package list in Xcode. + +```swift +dependencies: [ + .package(url: "https://github.com/circlefin/modularwallets-ios-sdk.git", .upToNextMajor(from: "1.0.0")) +] +``` + +Normally you'll want to depend on the `CircleModularWalletsCore` target: + +```swift +.product(name: "CircleModularWalletsCore", package: "CircleModularWalletsCore") +``` + +## Example + +### Users can create smart accounts and send UserOps with passkeys with sample code + +> ```swift +> import CircleModularWalletsCore +> +> let CLIENT_KEY = "xxxxxxx:xxxxx" +> +> Task { +> do { +> // 1. SDK calls RP to create/login a user +> +> // Create a PasskeyTransport with client key +> let transport = toPasskeyTransport(clientKey: CLIENT_KEY) +> +> let credential = try await toWebAuthnCredential( +> transport: transport, +> userName: "MyExampleName", // userName +> mode: WebAuthnMode.register // or WebAuthnMode.login +> ) +> +> // 2. Create a WebAuthn owner account from the credential. +> let webAuthnAccount = toWebAuthnAccount( +> credential +> ) +> +> // 3. Create modularTrasport with chain and client key then create a bundlerClient +> let modularTrasport = toModularTransport( +> clientKey: CLIENT_KEY, +> url: clientUrl +> ) +> +> let client = BundlerClient( +> chain: Sepolia, +> transport: modularTrasport +> ) +> +> // 4. Create SmartAccout(CircleSmartAccount) and set the WebAuthn account as the owner +> let smartAccount = try await toCircleSmartAccount( +> client: client, +> owner: webAuthnAccount +> ) +> +> // 5. Send an User Operation to the Bundler. For the example below, we will send 1 ETH to a random address. +> let hash = bundlerClient.sendUserOperation( +> account: account, +> calls: [ +> EncodeCallDataArg( +> to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", +> value: UnitUtils.parseEtherToWei("1") +> ) +> ] +> ) +> } catch { +> print(error) +> } +> } +> ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dee1a30 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not file public issues on Github for security vulnerabilities. All security vulnerabilities should be reported to Circle privately, through Circle's [Vulnerability Disclosure Program](https://hackerone.com/circle). Please read through the program policy before submitting a report.