diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 1e887a78c..75ed3bd4f 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -15,7 +15,7 @@ runs: run: "xcodebuild \ -project Example/ExampleApp.xcodeproj \ -scheme WalletConnect \ - -clonedSourcePackagesDirPath SourcePackages \ + -clonedSourcePackagesDirPath SourcePackagesCache \ -destination 'platform=iOS Simulator,name=iPhone 13' \ test" @@ -26,7 +26,7 @@ runs: run: "xcodebuild \ -project Example/ExampleApp.xcodeproj \ -scheme IntegrationTests \ - -clonedSourcePackagesDirPath SourcePackages \ + -clonedSourcePackagesDirPath SourcePackagesCache \ -destination 'platform=iOS Simulator,name=iPhone 13' test" # Wallet build @@ -36,7 +36,7 @@ runs: run: "xcodebuild \ -project Example/ExampleApp.xcodeproj \ -scheme Wallet \ - -clonedSourcePackagesDirPath SourcePackages \ + -clonedSourcePackagesDirPath SourcePackagesCache \ -sdk iphonesimulator" # DApp build @@ -46,7 +46,7 @@ runs: run: "xcodebuild \ -project Example/ExampleApp.xcodeproj \ -scheme DApp \ - -clonedSourcePackagesDirPath SourcePackages \ + -clonedSourcePackagesDirPath SourcePackagesCache \ -sdk iphonesimulator" # UI tests @@ -56,6 +56,6 @@ runs: run: "xcodebuild \ -project Example/ExampleApp.xcodeproj \ -scheme UITests \ - -clonedSourcePackagesDirPath SourcePackages \ + -clonedSourcePackagesDirPath SourcePackagesCache \ -destination 'platform=iOS Simulator,name=iPhone 13' test" continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 297e36c28..bcbcf09cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,15 +24,21 @@ jobs: steps: - uses: actions/checkout@v2 - + - name: Setup Xcode Version uses: maxim-lobanov/setup-xcode@v1 + - name: Resolve Dependencies + shell: bash + run: " + xcodebuild -resolvePackageDependencies -project Example/ExampleApp.xcodeproj -scheme DApp -clonedSourcePackagesDirPath SourcePackagesCache; \ + xcodebuild -resolvePackageDependencies -project Example/ExampleApp.xcodeproj -scheme WalletConnect -clonedSourcePackagesDirPath SourcePackagesCache" + - uses: actions/cache@v2 with: path: | .build - SourcePackages + SourcePackagesCache key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} restore-keys: | ${{ runner.os }}-spm- @@ -58,7 +64,7 @@ jobs: with: path: | .build - SourcePackages + SourcePackagesCache key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} restore-keys: | ${{ runner.os }}-spm- diff --git a/.github/workflows/intake.yml b/.github/workflows/intake.yml index 4b79bc326..210a8e04e 100644 --- a/.github/workflows/intake.yml +++ b/.github/workflows/intake.yml @@ -1,4 +1,4 @@ -# This workflow moves issues to the Swift board +# This workflow moves issues to the board # when they receive the "accepted" label # When WalletConnect Org members create issues they # are automatically "accepted". @@ -28,15 +28,20 @@ jobs: if: github.event.action == 'opened' runs-on: ubuntu-latest steps: - - name: Check if organization member - id: is_organization_member - if: github.event.action == 'opened' - uses: JamesSingleton/is-organization-member@1.0.0 + - name: Check Core Team membership + uses: tspascoal/get-user-teams-membership@v1 + id: is-core-team with: - organization: WalletConnect username: ${{ github.event_name != 'pull_request' && github.event.issue.user.login || github.event.sender.login }} - token: ${{ secrets.ASSIGN_TO_PROJECT_GITHUB_TOKEN }} + team: "Core Team" + GITHUB_TOKEN: ${{ secrets.ASSIGN_TO_PROJECT_GITHUB_TOKEN }} + - name: Print result + env: + CREATOR: ${{ github.event_name != 'pull_request' && github.event.issue.user.login || github.event.sender.login }} + IS_TEAM_MEMBER: ${{ steps.is-core-team.outputs.isTeamMember }} + run: echo "$CREATOR (Core Team Member $IS_TEAM_MEMBER) created this issue/PR" - name: Label issues + if: ${{ steps.is-core-team.outputs.isTeamMember == 'true' }} uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90 with: add-labels: "accepted" diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectAuth.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectAuth.xcscheme index 5b05c7da2..10b74b10a 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectAuth.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectAuth.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + private let viewModel: SessionDetailViewModel - init(session: Session, client: Sign) { + init(session: Session, client: SignClient) { self.viewModel = SessionDetailViewModel(session: session, client: client) super.init(rootView: SessionDetailView(viewModel: viewModel)) diff --git a/Example/ExampleApp/SessionDetails/SessionDetailViewModel.swift b/Example/ExampleApp/SessionDetails/SessionDetailViewModel.swift index 00e2c18d2..f29dea255 100644 --- a/Example/ExampleApp/SessionDetails/SessionDetailViewModel.swift +++ b/Example/ExampleApp/SessionDetails/SessionDetailViewModel.swift @@ -5,7 +5,7 @@ import WalletConnectSign @MainActor final class SessionDetailViewModel: ObservableObject { private let session: Session - private let client: Sign + private let client: SignClient enum Fields { case accounts @@ -18,7 +18,7 @@ final class SessionDetailViewModel: ObservableObject { @Published var pingSuccess: Bool = false @Published var pingFailed: Bool = false - init(session: Session, client: Sign) { + init(session: Session, client: SignClient) { self.session = session self.client = client self.namespaces = session.namespaces diff --git a/Example/ExampleApp/Wallet/WalletViewController.swift b/Example/ExampleApp/Wallet/WalletViewController.swift index cc9b001e6..5553c823e 100644 --- a/Example/ExampleApp/Wallet/WalletViewController.swift +++ b/Example/ExampleApp/Wallet/WalletViewController.swift @@ -1,6 +1,7 @@ import UIKit import WalletConnectSign import WalletConnectUtils +import WalletConnectRouter import Web3 import CryptoSwift import Combine @@ -23,6 +24,7 @@ final class WalletViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() navigationItem.title = "Wallet" + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(goBack)) walletView.scanButton.addTarget(self, action: #selector(showScanner), for: .touchUpInside) walletView.pasteButton.addTarget(self, action: #selector(showTextInput), for: .touchUpInside) @@ -48,6 +50,11 @@ final class WalletViewController: UIViewController { present(alert, animated: true) } + @objc + private func goBack() { + Router.goBack() + } + private func showSessionProposal(_ proposal: Proposal) { let proposalViewController = ProposalViewController(proposal: proposal) proposalViewController.delegate = self @@ -222,7 +229,7 @@ extension WalletViewController: ProposalViewControllerDelegate { func didRejectSession() { let proposal = currentProposal! currentProposal = nil - reject(proposalId: proposal.id, reason: .disapprovedChains) + reject(proposalId: proposal.id, reason: .userRejectedChains) } } diff --git a/Example/IntegrationTests/Auth/AuthTests.swift b/Example/IntegrationTests/Auth/AuthTests.swift new file mode 100644 index 000000000..731d74b14 --- /dev/null +++ b/Example/IntegrationTests/Auth/AuthTests.swift @@ -0,0 +1,123 @@ +import Foundation +import XCTest +import WalletConnectUtils +@testable import WalletConnectKMS +import WalletConnectRelay +import Combine +@testable import Auth + +final class AuthTests: XCTestCase { + var app: AuthClient! + var wallet: AuthClient! + let prvKey = Data(hex: "462c1dad6832d7d96ccf87bd6a686a4110e114aaaebd5512e552c0e3a87b480f") + private var publishers = [AnyCancellable]() + + override func setUp() { + app = makeClient(prefix: "👻 App") + let walletAccount = Account(chainIdentifier: "eip155:1", address: "0x724d0D2DaD3fbB0C168f947B87Fa5DBe36F1A8bf")! + wallet = makeClient(prefix: "🤑 Wallet", account: walletAccount) + + let expectation = expectation(description: "Wait Clients Connected") + expectation.expectedFulfillmentCount = 2 + + app.socketConnectionStatusPublisher.sink { status in + if status == .connected { + expectation.fulfill() + } + }.store(in: &publishers) + + wallet.socketConnectionStatusPublisher.sink { status in + if status == .connected { + expectation.fulfill() + } + }.store(in: &publishers) + + wait(for: [expectation], timeout: 5) + } + + func makeClient(prefix: String, account: Account? = nil) -> AuthClient { + let logger = ConsoleLogger(suffix: prefix, loggingLevel: .debug) + let relayHost = "relay.walletconnect.com" + let projectId = "8ba9ee138960775e5231b70cc5ef1c3a" + let keychain = KeychainStorageMock() + let relayClient = RelayClient(relayHost: relayHost, projectId: projectId, keychainStorage: keychain, socketFactory: SocketFactory(), logger: logger) + + return AuthClientFactory.create( + metadata: AppMetadata(name: name, description: "", url: "", icons: [""]), + account: account, + logger: logger, + keyValueStorage: RuntimeKeyValueStorage(), + keychainStorage: keychain, + relayClient: relayClient) + } + + func testRequest() async { + let requestExpectation = expectation(description: "request delivered to wallet") + let uri = try! await app.request(RequestParams.stub()) + try! await wallet.pair(uri: uri) + wallet.authRequestPublisher.sink { _ in + requestExpectation.fulfill() + }.store(in: &publishers) + wait(for: [requestExpectation], timeout: 2) + } + + func testRespondSuccess() async { + let responseExpectation = expectation(description: "successful response delivered") + let uri = try! await app.request(RequestParams.stub()) + try! await wallet.pair(uri: uri) + wallet.authRequestPublisher.sink { [unowned self] (id, message) in + Task(priority: .high) { + let signature = try! MessageSigner().sign(message: message, privateKey: prvKey) + let cacaoSignature = CacaoSignature(t: "eip191", s: signature) + try! await wallet.respond(requestId: id, signature: cacaoSignature) + } + } + .store(in: &publishers) + app.authResponsePublisher.sink { (id, result) in + guard case .success = result else { XCTFail(); return } + responseExpectation.fulfill() + } + .store(in: &publishers) + wait(for: [responseExpectation], timeout: 5) + } + + func testUserRespondError() async { + let responseExpectation = expectation(description: "error response delivered") + let uri = try! await app.request(RequestParams.stub()) + try! await wallet.pair(uri: uri) + wallet.authRequestPublisher.sink { [unowned self] (id, message) in + Task(priority: .high) { + try! await wallet.reject(requestId: id) + } + } + .store(in: &publishers) + app.authResponsePublisher.sink { (id, result) in + guard case .failure(let error) = result else { XCTFail(); return } + XCTAssertEqual(error, .userRejeted) + responseExpectation.fulfill() + } + .store(in: &publishers) + wait(for: [responseExpectation], timeout: 5) + } + + func testRespondSignatureVerificationFailed() async { + let responseExpectation = expectation(description: "invalid signature response delivered") + let uri = try! await app.request(RequestParams.stub()) + try! await wallet.pair(uri: uri) + wallet.authRequestPublisher.sink { [unowned self] (id, message) in + Task(priority: .high) { + let invalidSignature = "438effc459956b57fcd9f3dac6c675f9cee88abf21acab7305e8e32aa0303a883b06dcbd956279a7a2ca21ffa882ff55cc22e8ab8ec0f3fe90ab45f306938cfa1b" + let cacaoSignature = CacaoSignature(t: "eip191", s: invalidSignature) + try! await wallet.respond(requestId: id, signature: cacaoSignature) + } + } + .store(in: &publishers) + app.authResponsePublisher.sink { (id, result) in + guard case .failure(let error) = result else { XCTFail(); return } + XCTAssertEqual(error, .signatureVerificationFailed) + responseExpectation.fulfill() + } + .store(in: &publishers) + wait(for: [responseExpectation], timeout: 2) + } +} diff --git a/Example/IntegrationTests/Chat/ChatTests.swift b/Example/IntegrationTests/Chat/ChatTests.swift index 812b34e05..aa1a6271f 100644 --- a/Example/IntegrationTests/Chat/ChatTests.swift +++ b/Example/IntegrationTests/Chat/ChatTests.swift @@ -66,7 +66,7 @@ final class ChatTests: XCTestCase { let inviteeAccount = Account(chainIdentifier: "eip155:1", address: "0x3627523167367216556273151")! let inviterAccount = Account(chainIdentifier: "eip155:1", address: "0x36275231673672234423f")! - Task(priority: .background) { + Task(priority: .high) { let pubKey = try! await invitee.register(account: inviteeAccount) try! await inviter.invite(publicKey: pubKey, peerAccount: inviteeAccount, openingMessage: "opening message", account: inviterAccount) @@ -94,7 +94,7 @@ final class ChatTests: XCTestCase { let inviteeAccount = Account(chainIdentifier: "eip155:1", address: "0x3627523167367216556273151")! let inviterAccount = Account(chainIdentifier: "eip155:1", address: "0x36275231673672234423f")! - Task(priority: .background) { + Task(priority: .high) { let pubKey = try! await invitee.register(account: inviteeAccount) try! await inviter.invite(publicKey: pubKey, peerAccount: inviteeAccount, openingMessage: "opening message", account: inviterAccount) } diff --git a/Example/IntegrationTests/Relay/RelayClientEndToEndTests.swift b/Example/IntegrationTests/Relay/RelayClientEndToEndTests.swift index 44f329ac4..a634f648b 100644 --- a/Example/IntegrationTests/Relay/RelayClientEndToEndTests.swift +++ b/Example/IntegrationTests/Relay/RelayClientEndToEndTests.swift @@ -67,14 +67,16 @@ final class RelayClientEndToEndTests: XCTestCase { expectationA.assertForOverFulfill = false expectationB.assertForOverFulfill = false - relayA.onMessage = { topic, payload in + relayA.messagePublisher.sink { topic, payload in (subscriptionATopic, subscriptionAPayload) = (topic, payload) expectationA.fulfill() - } - relayB.onMessage = { topic, payload in + }.store(in: &publishers) + + relayB.messagePublisher.sink { topic, payload in (subscriptionBTopic, subscriptionBPayload) = (topic, payload) expectationB.fulfill() - } + }.store(in: &publishers) + relayA.socketConnectionStatusPublisher.sink { _ in relayA.publish(topic: randomTopic, payload: payloadA, tag: 0, onNetworkAcknowledge: { error in XCTAssertNil(error) diff --git a/Example/IntegrationTests/Sign/Helpers/ClientDelegate.swift b/Example/IntegrationTests/Sign/Helpers/ClientDelegate.swift index b048db4f8..71ac99ab3 100644 --- a/Example/IntegrationTests/Sign/Helpers/ClientDelegate.swift +++ b/Example/IntegrationTests/Sign/Helpers/ClientDelegate.swift @@ -1,10 +1,8 @@ import Foundation @testable import WalletConnectSign +import Combine -class ClientDelegate: SignClientDelegate { - func didChangeSocketConnectionStatus(_ status: SocketConnectionStatus) { - onConnected?() - } +class ClientDelegate { var client: SignClient var onSessionSettled: ((Session) -> Void)? @@ -15,43 +13,55 @@ class ClientDelegate: SignClientDelegate { var onSessionRejected: ((Session.Proposal, Reason) -> Void)? var onSessionDelete: (() -> Void)? var onSessionUpdateNamespaces: ((String, [String: SessionNamespace]) -> Void)? - var onSessionUpdateEvents: ((String, Set) -> Void)? var onSessionExtend: ((String, Date) -> Void)? var onEventReceived: ((Session.Event, String) -> Void)? - var onPairingUpdate: ((Pairing) -> Void)? - internal init(client: SignClient) { + private var publishers = Set() + + init(client: SignClient) { self.client = client - client.delegate = self + setupSubscriptions() } - func didReject(proposal: Session.Proposal, reason: Reason) { - onSessionRejected?(proposal, reason) - } - func didSettle(session: Session) { - onSessionSettled?(session) - } - func didReceive(sessionProposal: Session.Proposal) { - onSessionProposal?(sessionProposal) - } - func didReceive(sessionRequest: Request) { - onSessionRequest?(sessionRequest) - } - func didDelete(sessionTopic: String, reason: Reason) { - onSessionDelete?() - } - func didUpdate(sessionTopic: String, namespaces: [String: SessionNamespace]) { - onSessionUpdateNamespaces?(sessionTopic, namespaces) - } - func didExtend(sessionTopic: String, to date: Date) { - onSessionExtend?(sessionTopic, date) - } - func didReceive(event: Session.Event, sessionTopic: String, chainId: Blockchain?) { - onEventReceived?(event, sessionTopic) - } - func didReceive(sessionResponse: Response) { - onSessionResponse?(sessionResponse) - } + private func setupSubscriptions() { + client.sessionSettlePublisher.sink { session in + self.onSessionSettled?(session) + }.store(in: &publishers) + + client.socketConnectionStatusPublisher.sink { _ in + self.onConnected?() + }.store(in: &publishers) + + client.sessionProposalPublisher.sink { proposal in + self.onSessionProposal?(proposal) + }.store(in: &publishers) + + client.sessionRequestPublisher.sink { request in + self.onSessionRequest?(request) + }.store(in: &publishers) + + client.sessionResponsePublisher.sink { response in + self.onSessionResponse?(response) + }.store(in: &publishers) - func didDisconnect() {} + client.sessionRejectionPublisher.sink { (proposal, reason) in + self.onSessionRejected?(proposal, reason) + }.store(in: &publishers) + + client.sessionDeletePublisher.sink { _ in + self.onSessionDelete?() + }.store(in: &publishers) + + client.sessionUpdatePublisher.sink { (topic, namespaces) in + self.onSessionUpdateNamespaces?(topic, namespaces) + }.store(in: &publishers) + + client.sessionEventPublisher.sink { (event, topic, _) in + self.onEventReceived?(event, topic) + }.store(in: &publishers) + + client.sessionExtendPublisher.sink { (topic, date) in + self.onSessionExtend?(topic, date) + }.store(in: &publishers) + } } diff --git a/Example/IntegrationTests/Sign/SignClientTests.swift b/Example/IntegrationTests/Sign/SignClientTests.swift index 4e53da7ae..cd79047f4 100644 --- a/Example/IntegrationTests/Sign/SignClientTests.swift +++ b/Example/IntegrationTests/Sign/SignClientTests.swift @@ -101,7 +101,7 @@ final class SignClientTests: XCTestCase { wallet.onSessionProposal = { [unowned self] proposal in Task(priority: .high) { do { - try await wallet.client.reject(proposalId: proposal.id, reason: .disapprovedChains) // TODO: Review reason + try await wallet.client.reject(proposalId: proposal.id, reason: .userRejectedChains) // TODO: Review reason store.rejectedProposal = proposal } catch { XCTFail("\(error)") } } diff --git a/Example/IntegrationTests/Stubs/RequestParams.swift b/Example/IntegrationTests/Stubs/RequestParams.swift new file mode 100644 index 000000000..89cb11ae9 --- /dev/null +++ b/Example/IntegrationTests/Stubs/RequestParams.swift @@ -0,0 +1,25 @@ + +import Foundation +@testable import Auth + +extension RequestParams { + static func stub(domain: String = "service.invalid", + chainId: String = "1", + nonce: String = "32891756", + aud: String = "https://service.invalid/login", + nbf: String? = nil, + exp: String? = nil, + statement: String? = "I accept the ServiceOrg Terms of Service: https://service.invalid/tos", + requestId: String? = nil, + resources: [String]? = ["ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/", "https://example.com/my-web2-claim.json"]) -> RequestParams { + return RequestParams(domain: domain, + chainId: chainId, + nonce: nonce, + aud: aud, + nbf: nbf, + exp: exp, + statement: statement, + requestId: requestId, + resources: resources) + } +} diff --git a/Package.swift b/Package.swift index a94e15017..22c22aac8 100644 --- a/Package.swift +++ b/Package.swift @@ -17,10 +17,15 @@ let package = Package( name: "WalletConnectChat", targets: ["Chat"]), .library( - name: "WalletConnectPairing", - targets: ["WalletConnectPairing"]) + name: "WalletConnectAuth", + targets: ["Auth"]), + .library( + name: "WalletConnectRouter", + targets: ["WalletConnectRouter"]) + ], + dependencies: [ + .package(url: "https://github.com/flypaper0/Web3.swift", .branch("feature/eip-155")) ], - dependencies: [], targets: [ .target( name: "WalletConnectSign", @@ -32,7 +37,13 @@ let package = Package( path: "Sources/Chat"), .target( name: "Auth", - dependencies: ["WalletConnectRelay", "WalletConnectUtils", "WalletConnectKMS", "WalletConnectPairing"], + dependencies: [ + "WalletConnectRelay", + "WalletConnectUtils", + "WalletConnectKMS", + "WalletConnectPairing", + .product(name: "Web3", package: "Web3.swift") + ], path: "Sources/Auth"), .target( name: "WalletConnectRelay", @@ -54,6 +65,9 @@ let package = Package( .target( name: "Commons", dependencies: []), + .target( + name: "WalletConnectRouter", + dependencies: []), .testTarget( name: "WalletConnectSignTests", dependencies: ["WalletConnectSign", "TestingUtils"]), diff --git a/Sources/Auth/Auth.swift b/Sources/Auth/Auth.swift new file mode 100644 index 000000000..ab10c1f39 --- /dev/null +++ b/Sources/Auth/Auth.swift @@ -0,0 +1,26 @@ +import Foundation +import WalletConnectRelay +import Combine + +public class Auth { + + public static var instance: AuthClient = { + guard let config = Auth.config else { + fatalError("Error - you must call Auth.configure(_:) before accessing the shared instance.") + } + return AuthClientFactory.create( + metadata: config.metadata, + account: config.account, + relayClient: Relay.instance) + }() + + private static var config: Config? + + private init() { } + + static public func configure(metadata: AppMetadata, account: Account?) { + Auth.config = Auth.Config( + metadata: metadata, + account: account) + } +} diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index a9c751104..158e54a2f 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1,31 +1,123 @@ import Foundation +import Combine +import WalletConnectUtils +import WalletConnectPairing +import WalletConnectRelay -class AuthClient { + +public class AuthClient { enum Errors: Error { case malformedPairingURI + case unknownWalletAddress + case noPairingMatchingTopic + } + private var authRequestPublisherSubject = PassthroughSubject<(id: RPCID, message: String), Never>() + public var authRequestPublisher: AnyPublisher<(id: RPCID, message: String), Never> { + authRequestPublisherSubject.eraseToAnyPublisher() + } + + private var authResponsePublisherSubject = PassthroughSubject<(id: RPCID, result: Result), Never>() + public var authResponsePublisher: AnyPublisher<(id: RPCID, result: Result), Never> { + authResponsePublisherSubject.eraseToAnyPublisher() } + public let socketConnectionStatusPublisher: AnyPublisher + private let appPairService: AppPairService - private let appRequestService: AuthRequestService + private let appRequestService: AppRequestService + private let appRespondSubscriber: AppRespondSubscriber private let walletPairService: WalletPairService + private let walletRequestSubscriber: WalletRequestSubscriber + private let walletRespondService: WalletRespondService + private let cleanupService: CleanupService + private let pairingStorage: WCPairingStorage + private let pendingRequestsProvider: PendingRequestsProvider + public let logger: ConsoleLogging + + private var account: Account? - init(appPairService: AppPairService, appRequestService: AuthRequestService, walletPairService: WalletPairService) { + init(appPairService: AppPairService, + appRequestService: AppRequestService, + appRespondSubscriber: AppRespondSubscriber, + walletPairService: WalletPairService, + walletRequestSubscriber: WalletRequestSubscriber, + walletRespondService: WalletRespondService, + account: Account?, + pendingRequestsProvider: PendingRequestsProvider, + cleanupService: CleanupService, + logger: ConsoleLogging, + pairingStorage: WCPairingStorage, + socketConnectionStatusPublisher: AnyPublisher +) { self.appPairService = appPairService self.appRequestService = appRequestService self.walletPairService = walletPairService + self.walletRequestSubscriber = walletRequestSubscriber + self.walletRespondService = walletRespondService + self.appRespondSubscriber = appRespondSubscriber + self.account = account + self.pendingRequestsProvider = pendingRequestsProvider + self.cleanupService = cleanupService + self.logger = logger + self.pairingStorage = pairingStorage + self.socketConnectionStatusPublisher = socketConnectionStatusPublisher + + setUpPublishers() } - func request(params: RequestParams) async throws -> String { + public func pair(uri: String) async throws { + guard let pairingURI = WalletConnectURI(string: uri) else { + throw Errors.malformedPairingURI + } + try await walletPairService.pair(pairingURI) + } + + public func request(_ params: RequestParams) async throws -> String { + logger.debug("Requesting Authentication") let uri = try await appPairService.create() try await appRequestService.request(params: params, topic: uri.topic) return uri.absoluteString } - func pair(uri: String) async throws { - guard let pairingURI = WalletConnectURI(string: uri) else { - throw Errors.malformedPairingURI + public func request(_ params: RequestParams, topic: String) async throws { + logger.debug("Requesting Authentication on existing pairing") + guard pairingStorage.hasPairing(forTopic: topic) else { + throw Errors.noPairingMatchingTopic + } + try await appRequestService.request(params: params, topic: topic) + } + + public func respond(requestId: RPCID, signature: CacaoSignature) async throws { + guard let account = account else { throw Errors.unknownWalletAddress } + try await walletRespondService.respond(requestId: requestId, signature: signature, account: account) + } + + public func reject(requestId: RPCID) async throws { + try await walletRespondService.respondError(requestId: requestId) + } + + public func getPendingRequests() throws -> [AuthRequest] { + guard let account = account else { throw Errors.unknownWalletAddress } + return try pendingRequestsProvider.getPendingRequests(account: account) + } + +#if DEBUG + /// Delete all stored data such as: pairings, keys + /// + /// - Note: Doesn't unsubscribe from topics + public func cleanup() throws { + try cleanupService.cleanup() + } +#endif + + private func setUpPublishers() { + appRespondSubscriber.onResponse = { [unowned self] (id, result) in + authResponsePublisherSubject.send((id, result)) + } + + walletRequestSubscriber.onRequest = { [unowned self] (id, message) in + authRequestPublisherSubject.send((id, message)) } - try await walletPairService.pair(pairingURI) } } diff --git a/Sources/Auth/AuthClientFactory.swift b/Sources/Auth/AuthClientFactory.swift new file mode 100644 index 000000000..6be615528 --- /dev/null +++ b/Sources/Auth/AuthClientFactory.swift @@ -0,0 +1,46 @@ +import Foundation +import WalletConnectRelay +import WalletConnectUtils +import WalletConnectKMS +import WalletConnectPairing + +public struct AuthClientFactory { + + public static func create(metadata: AppMetadata, account: Account?, relayClient: RelayClient) -> AuthClient { + let logger = ConsoleLogger(loggingLevel: .off) + let keyValueStorage = UserDefaults.standard + let keychainStorage = KeychainStorage(serviceIdentifier: "com.walletconnect.sdk") + return AuthClientFactory.create(metadata: metadata, account: account, logger: logger, keyValueStorage: keyValueStorage, keychainStorage: keychainStorage, relayClient: relayClient) + } + + static func create(metadata: AppMetadata, account: Account?, logger: ConsoleLogging, keyValueStorage: KeyValueStorage, keychainStorage: KeychainStorageProtocol, relayClient: RelayClient) -> AuthClient { + let historyStorage = CodableStore(defaults: keyValueStorage, identifier: StorageDomainIdentifiers.jsonRpcHistory.rawValue) + let pairingStore = PairingStorage(storage: SequenceStore(store: .init(defaults: keyValueStorage, identifier: StorageDomainIdentifiers.pairings.rawValue))) + let kms = KeyManagementService(keychain: keychainStorage) + let serializer = Serializer(kms: kms) + let history = RPCHistory(keyValueStore: historyStorage) + let networkingInteractor = NetworkingInteractor(relayClient: relayClient, serializer: serializer, logger: logger, rpcHistory: history) + let messageFormatter = SIWEMessageFormatter() + let appPairService = AppPairService(networkingInteractor: networkingInteractor, kms: kms, pairingStorage: pairingStore) + let appRequestService = AppRequestService(networkingInteractor: networkingInteractor, kms: kms, appMetadata: metadata, logger: logger) + let messageSigner = MessageSigner(signer: Signer()) + let appRespondSubscriber = AppRespondSubscriber(networkingInteractor: networkingInteractor, logger: logger, rpcHistory: history, signatureVerifier: messageSigner, messageFormatter: messageFormatter) + let walletPairService = WalletPairService(networkingInteractor: networkingInteractor, kms: kms, pairingStorage: pairingStore) + let walletRequestSubscriber = WalletRequestSubscriber(networkingInteractor: networkingInteractor, logger: logger, kms: kms, messageFormatter: messageFormatter, address: account?.address) + let walletRespondService = WalletRespondService(networkingInteractor: networkingInteractor, logger: logger, kms: kms, rpcHistory: history) + let pendingRequestsProvider = PendingRequestsProvider(rpcHistory: history) + let cleanupService = CleanupService(pairingStore: pairingStore, kms: kms) + + return AuthClient(appPairService: appPairService, + appRequestService: appRequestService, + appRespondSubscriber: appRespondSubscriber, + walletPairService: walletPairService, + walletRequestSubscriber: walletRequestSubscriber, + walletRespondService: walletRespondService, + account: account, + pendingRequestsProvider: pendingRequestsProvider, + cleanupService: cleanupService, + logger: logger, + pairingStorage: pairingStore, socketConnectionStatusPublisher: relayClient.socketConnectionStatusPublisher) + } +} diff --git a/Sources/Auth/AuthConfig.swift b/Sources/Auth/AuthConfig.swift new file mode 100644 index 000000000..b364ec507 --- /dev/null +++ b/Sources/Auth/AuthConfig.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Auth { + struct Config { + let metadata: AppMetadata + let account: Account? + } +} diff --git a/Sources/Auth/Extensions/Data+Keccak256.swift b/Sources/Auth/Extensions/Data+Keccak256.swift new file mode 100644 index 000000000..1bfbc1b8b --- /dev/null +++ b/Sources/Auth/Extensions/Data+Keccak256.swift @@ -0,0 +1,9 @@ +import Foundation +import CryptoSwift + +extension Data { + + var keccak256: Data { + return sha3(.keccak256) + } +} diff --git a/Sources/Auth/Services/App/AuthRequestService.swift b/Sources/Auth/Services/App/AppRequestService.swift similarity index 79% rename from Sources/Auth/Services/App/AuthRequestService.swift rename to Sources/Auth/Services/App/AppRequestService.swift index b2b50844d..b3186e77d 100644 --- a/Sources/Auth/Services/App/AuthRequestService.swift +++ b/Sources/Auth/Services/App/AppRequestService.swift @@ -3,17 +3,20 @@ import WalletConnectUtils import WalletConnectKMS import JSONRPC -actor AuthRequestService { +actor AppRequestService { private let networkingInteractor: NetworkInteracting private let appMetadata: AppMetadata private let kms: KeyManagementService + private let logger: ConsoleLogging init(networkingInteractor: NetworkInteracting, kms: KeyManagementService, - appMetadata: AppMetadata) { + appMetadata: AppMetadata, + logger: ConsoleLogging) { self.networkingInteractor = networkingInteractor self.kms = kms self.appMetadata = appMetadata + self.logger = logger } func request(params: RequestParams, topic: String) async throws { @@ -24,6 +27,8 @@ actor AuthRequestService { let payload = AuthPayload(requestParams: params, iat: issueAt) let params = AuthRequestParams(requester: requester, payloadParams: payload) let request = RPCRequest(method: "wc_authRequest", params: params) + try kms.setPublicKey(publicKey: pubKey, for: responseTopic) + logger.debug("AppRequestService: Subscribibg for response topic: \(responseTopic)") try await networkingInteractor.requestNetworkAck(request, topic: topic, tag: AuthRequestParams.tag) try await networkingInteractor.subscribe(topic: responseTopic) } diff --git a/Sources/Auth/Services/App/AppRespondSubscriber.swift b/Sources/Auth/Services/App/AppRespondSubscriber.swift new file mode 100644 index 000000000..a73015b4c --- /dev/null +++ b/Sources/Auth/Services/App/AppRespondSubscriber.swift @@ -0,0 +1,65 @@ +import Combine +import Foundation +import WalletConnectUtils +import JSONRPC + +class AppRespondSubscriber { + private let networkingInteractor: NetworkInteracting + private let logger: ConsoleLogging + private let rpcHistory: RPCHistory + private let signatureVerifier: MessageSignatureVerifying + private let messageFormatter: SIWEMessageFormatting + private var publishers = [AnyCancellable]() + + var onResponse: ((_ id: RPCID, _ result: Result) -> Void)? + + init(networkingInteractor: NetworkInteracting, + logger: ConsoleLogging, + rpcHistory: RPCHistory, + signatureVerifier: MessageSignatureVerifying, + messageFormatter: SIWEMessageFormatting) { + self.networkingInteractor = networkingInteractor + self.logger = logger + self.rpcHistory = rpcHistory + self.signatureVerifier = signatureVerifier + self.messageFormatter = messageFormatter + subscribeForResponse() + } + + private func subscribeForResponse() { + networkingInteractor.responsePublisher.sink { [unowned self] subscriptionPayload in + let response = subscriptionPayload.response + guard + let requestId = response.id, + let request = rpcHistory.get(recordId: requestId)?.request, + let requestParams = request.params, request.method == "wc_authRequest" + else { return } + + networkingInteractor.unsubscribe(topic: subscriptionPayload.topic) + + if let errorResponse = response.error, + let error = AuthError(code: errorResponse.code) { + onResponse?(requestId, .failure(error)) + return + } + + guard + let cacao = try? response.result?.get(Cacao.self), + let address = try? DIDPKH(iss: cacao.payload.iss).account.address, + let message = try? messageFormatter.formatMessage(from: cacao.payload) + else { self.onResponse?(requestId, .failure(.malformedResponseParams)); return } + + guard let requestPayload = try? requestParams.get(AuthRequestParams.self) + else { self.onResponse?(requestId, .failure(.malformedRequestParams)); return } + + guard messageFormatter.formatMessage(from: requestPayload.payloadParams, address: address) == message + else { self.onResponse?(requestId, .failure(.messageCompromised)); return } + + guard let _ = try? signatureVerifier.verify(signature: cacao.signature.s, message: message, address: address) + else { self.onResponse?(requestId, .failure(.signatureVerificationFailed)); return } + + onResponse?(requestId, .success(cacao)) + + }.store(in: &publishers) + } +} diff --git a/Sources/Auth/Services/App/AuthRespondSubscriber.swift b/Sources/Auth/Services/App/AuthRespondSubscriber.swift deleted file mode 100644 index 5fcd7355d..000000000 --- a/Sources/Auth/Services/App/AuthRespondSubscriber.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Combine -import Foundation -import WalletConnectUtils -import JSONRPC - -class AuthRespondSubscriber { - private let networkingInteractor: NetworkInteracting - private let logger: ConsoleLogging - private let rpcHistory: RPCHistory - private var publishers = [AnyCancellable]() - var onResponse: ((_ id: RPCID, _ cacao: Cacao) -> Void)? - - init(networkingInteractor: NetworkInteracting, - logger: ConsoleLogging, - rpcHistory: RPCHistory) { - self.networkingInteractor = networkingInteractor - self.logger = logger - self.rpcHistory = rpcHistory - subscribeForResponse() - } - - private func subscribeForResponse() { - networkingInteractor.responsePublisher.sink { [unowned self] subscriptionPayload in - guard let request = rpcHistory.get(recordId: subscriptionPayload.response.id!)?.request, - request.method == "wc_authRequest" else { return } - networkingInteractor.unsubscribe(topic: subscriptionPayload.topic) - guard let cacao = try? subscriptionPayload.response.result?.get(Cacao.self) else { - logger.debug("Malformed auth response params") - return - } - do { - try CacaoSignatureVerifier().verifySignature(cacao) - onResponse?(subscriptionPayload.response.id!, cacao) - } catch { - logger.debug("Received response with invalid signature") - } - }.store(in: &publishers) - } -} diff --git a/Sources/Auth/Services/Common/CacaoFormatter.swift b/Sources/Auth/Services/Common/CacaoFormatter.swift index 43ddb9058..b8331b577 100644 --- a/Sources/Auth/Services/Common/CacaoFormatter.swift +++ b/Sources/Auth/Services/Common/CacaoFormatter.swift @@ -2,11 +2,13 @@ import Foundation import WalletConnectUtils protocol CacaoFormatting { - func format(_ request: AuthRequestParams, _ signature: CacaoSignature, _ issuer: Account) -> Cacao + func format(_ request: AuthRequestParams, _ signature: CacaoSignature, _ didpkh: DIDPKH) -> Cacao } class CacaoFormatter: CacaoFormatting { - func format(_ request: AuthRequestParams, _ signature: CacaoSignature, _ issuer: Account) -> Cacao { - fatalError("not implemented") + func format(_ request: AuthRequestParams, _ signature: CacaoSignature, _ didpkh: DIDPKH) -> Cacao { + let header = CacaoHeader(t: "eip4361") + let payload = CacaoPayload(params: request.payloadParams, didpkh: didpkh) + return Cacao(header: header, payload: payload, signature: signature) } } diff --git a/Sources/Auth/Services/Common/CacaoSignatureVerifier.swift b/Sources/Auth/Services/Common/CacaoSignatureVerifier.swift deleted file mode 100644 index 64f70488b..000000000 --- a/Sources/Auth/Services/Common/CacaoSignatureVerifier.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -protocol CacaoSignatureVerifying { - func verifySignature(_ cacao: Cacao) throws -} - -class CacaoSignatureVerifier: CacaoSignatureVerifying { - enum Errors: Error { - case signatureInvalid - } - - func verifySignature(_ cacao: Cacao) throws { - fatalError("not implemented") - } -} diff --git a/Sources/Auth/Services/Common/CleanupService.swift b/Sources/Auth/Services/Common/CleanupService.swift new file mode 100644 index 000000000..6a5a01334 --- /dev/null +++ b/Sources/Auth/Services/Common/CleanupService.swift @@ -0,0 +1,20 @@ +import Foundation +import WalletConnectKMS +import WalletConnectUtils +import WalletConnectPairing + +final class CleanupService { + + private let pairingStore: WCPairingStorage + private let kms: KeyManagementServiceProtocol + + init(pairingStore: WCPairingStorage, kms: KeyManagementServiceProtocol) { + self.pairingStore = pairingStore + self.kms = kms + } + + func cleanup() throws { + pairingStore.deleteAll() + try kms.deleteAll() + } +} diff --git a/Sources/Auth/Services/Common/NetworkingInteractor.swift b/Sources/Auth/Services/Common/NetworkingInteractor.swift index ffdc61603..24419b295 100644 --- a/Sources/Auth/Services/Common/NetworkingInteractor.swift +++ b/Sources/Auth/Services/Common/NetworkingInteractor.swift @@ -13,6 +13,7 @@ protocol NetworkInteracting { func request(_ request: RPCRequest, topic: String, tag: Int, envelopeType: Envelope.EnvelopeType) async throws func requestNetworkAck(_ request: RPCRequest, topic: String, tag: Int) async throws func respond(topic: String, response: RPCResponse, tag: Int, envelopeType: Envelope.EnvelopeType) async throws + func respondError(topic: String, requestId: RPCID, tag: Int, reason: Reason, envelopeType: Envelope.EnvelopeType) async throws } extension NetworkInteracting { @@ -22,18 +23,22 @@ extension NetworkInteracting { } class NetworkingInteractor: NetworkInteracting { + private var publishers = Set() private let relayClient: RelayClient private let serializer: Serializing private let rpcHistory: RPCHistory private let logger: ConsoleLogging + + private let requestPublisherSubject = PassthroughSubject() var requestPublisher: AnyPublisher { requestPublisherSubject.eraseToAnyPublisher() } - private let requestPublisherSubject = PassthroughSubject() + + private let responsePublisherSubject = PassthroughSubject() var responsePublisher: AnyPublisher { responsePublisherSubject.eraseToAnyPublisher() } - private let responsePublisherSubject = PassthroughSubject() + var socketConnectionStatusPublisher: AnyPublisher init(relayClient: RelayClient, @@ -45,9 +50,10 @@ class NetworkingInteractor: NetworkInteracting { self.rpcHistory = rpcHistory self.logger = logger self.socketConnectionStatusPublisher = relayClient.socketConnectionStatusPublisher - relayClient.onMessage = { [unowned self] topic, message in + relayClient.messagePublisher.sink { [unowned self] (topic, message) in manageSubscription(topic, message) } + .store(in: &publishers) } func subscribe(topic: String) async throws { @@ -55,7 +61,13 @@ class NetworkingInteractor: NetworkInteracting { } func unsubscribe(topic: String) { - fatalError("not implemented") + relayClient.unsubscribe(topic: topic) { [unowned self] error in + if let error = error { + logger.error(error) + } else { + rpcHistory.deleteAll(forTopic: topic) + } + } } func request(_ request: RPCRequest, topic: String, tag: Int, envelopeType: Envelope.EnvelopeType) async throws { @@ -91,11 +103,18 @@ class NetworkingInteractor: NetworkInteracting { try await relayClient.publish(topic: topic, payload: message, tag: tag) } + func respondError(topic: String, requestId: RPCID, tag: Int, reason: Reason, envelopeType: Envelope.EnvelopeType) async throws { + let error = JSONRPCError(code: reason.code, message: reason.message) + let response = RPCResponse(id: requestId, error: error) + let message = try! serializer.serialize(topic: topic, encodable: response, envelopeType: envelopeType) + try await relayClient.publish(topic: topic, payload: message, tag: tag) + } + private func manageSubscription(_ topic: String, _ encodedEnvelope: String) { if let deserializedJsonRpcRequest: RPCRequest = serializer.tryDeserialize(topic: topic, encodedEnvelope: encodedEnvelope) { handleRequest(topic: topic, request: deserializedJsonRpcRequest) - } else if let deserializedJsonRpcResponse: RPCResponse = serializer.tryDeserialize(topic: topic, encodedEnvelope: encodedEnvelope) { - handleResponse(response: deserializedJsonRpcResponse) + } else if let response: RPCResponse = serializer.tryDeserialize(topic: topic, encodedEnvelope: encodedEnvelope) { + handleResponse(response: response) } else { logger.debug("Networking Interactor - Received unknown object type from networking relay") } diff --git a/Sources/Auth/Services/Common/SIWEMessageFormatter.swift b/Sources/Auth/Services/Common/SIWEMessageFormatter.swift index 1861e2808..64f6a6ced 100644 --- a/Sources/Auth/Services/Common/SIWEMessageFormatter.swift +++ b/Sources/Auth/Services/Common/SIWEMessageFormatter.swift @@ -3,22 +3,44 @@ import WalletConnectUtils protocol SIWEMessageFormatting { func formatMessage(from authPayload: AuthPayload, address: String) -> String + func formatMessage(from payload: CacaoPayload) throws -> String } struct SIWEMessageFormatter: SIWEMessageFormatting { - func formatMessage(from authPayload: AuthPayload, address: String) -> String { - SIWEMessage(domain: authPayload.domain, - uri: authPayload.aud, + func formatMessage(from payload: AuthPayload, address: String) -> String { + let message = SIWEMessage(domain: payload.domain, + uri: payload.aud, address: address, - version: authPayload.version, - nonce: authPayload.nonce, - chainId: authPayload.chainId, - iat: authPayload.iat, - nbf: authPayload.nbf, - exp: authPayload.exp, - statement: authPayload.statement, - requestId: authPayload.requestId, - resources: authPayload.resources).formatted + version: payload.version, + nonce: payload.nonce, + chainId: payload.chainId, + iat: payload.iat, + nbf: payload.nbf, + exp: payload.exp, + statement: payload.statement, + requestId: payload.requestId, + resources: payload.resources + ) + return message.formatted + } + + func formatMessage(from payload: CacaoPayload) throws -> String { + let address = try DIDPKH(iss: payload.iss).account.address + let message = SIWEMessage( + domain: payload.domain, + uri: payload.aud, + address: address, + version: payload.version, + nonce: payload.nonce, + chainId: "1", + iat: payload.iat, + nbf: payload.nbf, + exp: payload.exp, + statement: payload.statement, + requestId: payload.requestId, + resources: payload.resources + ) + return message.formatted } } diff --git a/Sources/Auth/Services/Signer/DIDPKH.swift b/Sources/Auth/Services/Signer/DIDPKH.swift new file mode 100644 index 000000000..fad1a3431 --- /dev/null +++ b/Sources/Auth/Services/Signer/DIDPKH.swift @@ -0,0 +1,33 @@ +import Foundation +import WalletConnectUtils + +struct DIDPKH { + static let didPrefix: String = "did:pkh" + + enum Errors: Error { + case invalidDIDPKH + case invalidAccount + } + + let account: Account + let iss: String + + init(iss: String) throws { + guard iss.starts(with: DIDPKH.didPrefix) + else { throw Errors.invalidDIDPKH } + + guard let string = iss.components(separatedBy: DIDPKH.didPrefix + ":").last + else { throw Errors.invalidDIDPKH } + + guard let account = Account(string) + else { throw Errors.invalidAccount } + + self.iss = iss + self.account = account + } + + init(account: Account) { + self.iss = "\(DIDPKH.didPrefix):\(account.absoluteString)" + self.account = account + } +} diff --git a/Sources/Auth/Services/Signer/MessageSigner.swift b/Sources/Auth/Services/Signer/MessageSigner.swift new file mode 100644 index 000000000..d07972a5c --- /dev/null +++ b/Sources/Auth/Services/Signer/MessageSigner.swift @@ -0,0 +1,36 @@ +import Foundation + +protocol MessageSignatureVerifying { + func verify(signature: String, message: String, address: String) throws +} + +protocol MessageSigning { + func sign(message: String, privateKey: Data) throws -> String +} + +public struct MessageSigner: MessageSignatureVerifying, MessageSigning { + + enum Errors: Error { + case signatureValidationFailed + case utf8EncodingFailed + } + + private let signer: Signer + + public init(signer: Signer = Signer()) { + self.signer = signer + } + + public func sign(message: String, privateKey: Data) throws -> String { + guard let messageData = message.data(using: .utf8) else { throw Errors.utf8EncodingFailed } + let signature = try signer.sign(message: messageData, with: privateKey) + return signature.toHexString() + } + + public func verify(signature: String, message: String, address: String) throws { + guard let messageData = message.data(using: .utf8) else { throw Errors.utf8EncodingFailed } + let signatureData = Data(hex: signature) + guard try signer.isValid(signature: signatureData, message: messageData, address: address) + else { throw Errors.signatureValidationFailed } + } +} diff --git a/Sources/Auth/Services/Signer/Signer.swift b/Sources/Auth/Services/Signer/Signer.swift new file mode 100644 index 000000000..3d5903296 --- /dev/null +++ b/Sources/Auth/Services/Signer/Signer.swift @@ -0,0 +1,44 @@ +import Foundation +import Web3 + +public struct Signer { + + typealias Signature = (v: UInt, r: [UInt8], s: [UInt8]) + + public init() {} + + func sign(message: Data, with key: Data) throws -> Data { + let prefixed = prefixed(message: message) + let privateKey = try EthereumPrivateKey(privateKey: key.bytes) + let signature = try privateKey.sign(message: prefixed.bytes) + return serialized(signature: signature) + } + + func isValid(signature: Data, message: Data, address: String) throws -> Bool { + let sig = decompose(signature: signature) + let prefixed = prefixed(message: message) + let publicKey = try EthereumPublicKey( + message: prefixed.bytes, + v: EthereumQuantity(quantity: BigUInt(sig.v)), + r: EthereumQuantity(sig.r), + s: EthereumQuantity(sig.s) + ) + return publicKey.address.hex(eip55: false) == address.lowercased() + } + + private func decompose(signature: Data) -> Signature { + let v = signature.bytes[signature.count-1] + let r = signature.bytes[0..<32] + let s = signature.bytes[32..<64] + return (UInt(v), [UInt8](r), [UInt8](s)) + } + + private func serialized(signature: Signature) -> Data { + return Data(signature.r + signature.s + [UInt8(signature.v)]) + } + + private func prefixed(message: Data) -> Data { + return "\u{19}Ethereum Signed Message:\n\(message.count)" + .data(using: .utf8)! + message + } +} diff --git a/Sources/Auth/Services/Wallet/AuthRequestSubscriber.swift b/Sources/Auth/Services/Wallet/AuthRequestSubscriber.swift deleted file mode 100644 index e4acc5b04..000000000 --- a/Sources/Auth/Services/Wallet/AuthRequestSubscriber.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Combine -import Foundation -import WalletConnectUtils -import JSONRPC - -class AuthRequestSubscriber { - private let networkingInteractor: NetworkInteracting - private let logger: ConsoleLogging - private let address: String - private var publishers = [AnyCancellable]() - private let messageFormatter: SIWEMessageFormatting - var onRequest: ((_ id: RPCID, _ message: String)->Void)? - - init(networkingInteractor: NetworkInteracting, - logger: ConsoleLogging, - messageFormatter: SIWEMessageFormatting, - address: String) { - self.networkingInteractor = networkingInteractor - self.logger = logger - self.address = address - self.messageFormatter = messageFormatter - subscribeForRequest() - } - - private func subscribeForRequest() { - networkingInteractor.requestPublisher.sink { [unowned self] subscriptionPayload in - guard subscriptionPayload.request.method == "wc_authRequest" else { return } - guard let authRequestParams = try? subscriptionPayload.request.params?.get(AuthRequestParams.self) else { - logger.debug("Malformed auth request params") - return - } - do { - let message = try messageFormatter.formatMessage(from: authRequestParams.payloadParams, address: address) - guard let requestId = subscriptionPayload.request.id else { return } - onRequest?(requestId, message) - } catch { - logger.debug(error) - } - }.store(in: &publishers) - } - -} diff --git a/Sources/Auth/Services/Wallet/AuthRespondService.swift b/Sources/Auth/Services/Wallet/AuthRespondService.swift deleted file mode 100644 index 006459d37..000000000 --- a/Sources/Auth/Services/Wallet/AuthRespondService.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import WalletConnectKMS -import JSONRPC -import WalletConnectUtils - -actor AuthRespondService { - enum Errors: Error { - case recordForIdNotFound - case malformedAuthRequestParams - } - private let networkingInteractor: NetworkInteracting - private let kms: KeyManagementService - private let rpcHistory: RPCHistory - private let logger: ConsoleLogging - - init(networkingInteractor: NetworkInteracting, - logger: ConsoleLogging, - kms: KeyManagementService, - rpcHistory: RPCHistory) { - self.networkingInteractor = networkingInteractor - self.logger = logger - self.kms = kms - self.rpcHistory = rpcHistory - } - - func respond(respondParams: RespondParams, issuer: Account) async throws { - guard let request = rpcHistory.get(recordId: RPCID(respondParams.id))?.request else { throw Errors.recordForIdNotFound } - guard let authRequestParams = try? request.params?.get(AuthRequestParams.self) else { throw Errors.malformedAuthRequestParams } - - let peerPubKey = authRequestParams.requester.publicKey - let responseTopic = peerPubKey.rawRepresentation.sha256().toHexString() - let selfPubKey = try kms.createX25519KeyPair() - let agreementKeys = try kms.performKeyAgreement(selfPublicKey: selfPubKey, peerPublicKey: peerPubKey) - try kms.setAgreementSecret(agreementKeys, topic: responseTopic) - - let cacao = CacaoFormatter().format(authRequestParams, respondParams.signature, issuer) - let response = RPCResponse(id: request.id!, result: cacao) - - try await networkingInteractor.respond(topic: respondParams.topic, response: response, tag: AuthResponseParams.tag, envelopeType: .type1(pubKey: selfPubKey.rawRepresentation)) - } -} diff --git a/Sources/Auth/Services/Wallet/PendingRequestsProvider.swift b/Sources/Auth/Services/Wallet/PendingRequestsProvider.swift new file mode 100644 index 000000000..16fe22128 --- /dev/null +++ b/Sources/Auth/Services/Wallet/PendingRequestsProvider.swift @@ -0,0 +1,22 @@ +import Foundation +import JSONRPC +import WalletConnectUtils + +class PendingRequestsProvider { + private let rpcHistory: RPCHistory + + init(rpcHistory: RPCHistory) { + self.rpcHistory = rpcHistory + } + + public func getPendingRequests(account: Account) throws -> [AuthRequest] { + let pendingRequests: [AuthRequest] = rpcHistory.getPending() + .filter {$0.request.method == "wc_authRequest"} + .compactMap { + guard let params = try? $0.request.params?.get(AuthRequestParams.self) else {return nil} + let message = SIWEMessageFormatter().formatMessage(from: params.payloadParams, address: account.address) + return AuthRequest(id: $0.request.id!, message: message) + } + return pendingRequests + } +} diff --git a/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift b/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift new file mode 100644 index 000000000..6d04629ca --- /dev/null +++ b/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift @@ -0,0 +1,59 @@ +import Combine +import Foundation +import WalletConnectUtils +import JSONRPC +import WalletConnectKMS + +class WalletRequestSubscriber { + private let networkingInteractor: NetworkInteracting + private let logger: ConsoleLogging + private let kms: KeyManagementServiceProtocol + private let address: String? + private var publishers = [AnyCancellable]() + private let messageFormatter: SIWEMessageFormatting + var onRequest: ((_ id: RPCID, _ message: String) -> Void)? + + init(networkingInteractor: NetworkInteracting, + logger: ConsoleLogging, + kms: KeyManagementServiceProtocol, + messageFormatter: SIWEMessageFormatting, + address: String?) { + self.networkingInteractor = networkingInteractor + self.logger = logger + self.kms = kms + self.address = address + self.messageFormatter = messageFormatter + subscribeForRequest() + } + + private func subscribeForRequest() { + guard let address = address else { return } + + networkingInteractor.requestPublisher.sink { [unowned self] payload in + + logger.debug("WalletRequestSubscriber: Received request") + + guard let requestId = payload.request.id, payload.request.method == "wc_authRequest" + else { return } + + guard let authRequestParams = try? payload.request.params?.get(AuthRequestParams.self) + else { return respondError(.malformedRequestParams, topic: payload.topic, requestId: requestId) } + + let message = messageFormatter.formatMessage(from: authRequestParams.payloadParams, address: address) + + onRequest?(requestId, message) + }.store(in: &publishers) + } + + private func respondError(_ error: AuthError, topic: String, requestId: RPCID) { + guard let pubKey = kms.getAgreementSecret(for: topic)?.publicKey + else { return logger.error("Agreement key for topic \(topic) not found") } + + let tag = AuthResponseParams.tag + let envelopeType = Envelope.EnvelopeType.type1(pubKey: pubKey.rawRepresentation) + + Task(priority: .high) { + try await networkingInteractor.respondError(topic: topic, requestId: requestId, tag: tag, reason: error, envelopeType: envelopeType) + } + } +} diff --git a/Sources/Auth/Services/Wallet/WalletRespondService.swift b/Sources/Auth/Services/Wallet/WalletRespondService.swift new file mode 100644 index 000000000..07e01871c --- /dev/null +++ b/Sources/Auth/Services/Wallet/WalletRespondService.swift @@ -0,0 +1,67 @@ +import Foundation +import WalletConnectKMS +import JSONRPC +import WalletConnectUtils + +actor WalletRespondService { + enum Errors: Error { + case recordForIdNotFound + case malformedAuthRequestParams + } + private let networkingInteractor: NetworkInteracting + private let kms: KeyManagementService + private let rpcHistory: RPCHistory + private let logger: ConsoleLogging + + init(networkingInteractor: NetworkInteracting, + logger: ConsoleLogging, + kms: KeyManagementService, + rpcHistory: RPCHistory) { + self.networkingInteractor = networkingInteractor + self.logger = logger + self.kms = kms + self.rpcHistory = rpcHistory + } + + func respond(requestId: RPCID, signature: CacaoSignature, account: Account) async throws { + let authRequestParams = try getAuthRequestParams(requestId: requestId) + let (topic, keys) = try generateAgreementKeys(requestParams: authRequestParams) + + try kms.setAgreementSecret(keys, topic: topic) + + let didpkh = DIDPKH(account: account) + let cacao = CacaoFormatter().format(authRequestParams, signature, didpkh) + let response = RPCResponse(id: requestId, result: cacao) + try await networkingInteractor.respond(topic: topic, response: response, tag: AuthResponseParams.tag, envelopeType: .type1(pubKey: keys.publicKey.rawRepresentation)) + } + + func respondError(requestId: RPCID) async throws { + let authRequestParams = try getAuthRequestParams(requestId: requestId) + let (topic, keys) = try generateAgreementKeys(requestParams: authRequestParams) + + try kms.setAgreementSecret(keys, topic: topic) + + let tag = AuthResponseParams.tag + let error = AuthError.userRejeted + let envelopeType = Envelope.EnvelopeType.type1(pubKey: keys.publicKey.rawRepresentation) + try await networkingInteractor.respondError(topic: topic, requestId: requestId, tag: tag, reason: error, envelopeType: envelopeType) + } + + private func getAuthRequestParams(requestId: RPCID) throws -> AuthRequestParams { + guard let request = rpcHistory.get(recordId: requestId)?.request + else { throw Errors.recordForIdNotFound } + + guard let authRequestParams = try request.params?.get(AuthRequestParams.self) + else { throw Errors.malformedAuthRequestParams } + + return authRequestParams + } + + private func generateAgreementKeys(requestParams: AuthRequestParams) throws -> (topic: String, keys: AgreementKeys) { + let peerPubKey = try AgreementPublicKey(hex: requestParams.requester.publicKey) + let topic = peerPubKey.rawRepresentation.sha256().toHexString() + let selfPubKey = try kms.createX25519KeyPair() + let keys = try kms.performKeyAgreement(selfPublicKey: selfPubKey, peerPublicKey: peerPubKey.hexRepresentation) + return (topic, keys) + } +} diff --git a/Sources/Auth/StorageDomainIdentifiers.swift b/Sources/Auth/StorageDomainIdentifiers.swift new file mode 100644 index 000000000..ed7156c67 --- /dev/null +++ b/Sources/Auth/StorageDomainIdentifiers.swift @@ -0,0 +1,6 @@ +import Foundation + +enum StorageDomainIdentifiers: String { + case jsonRpcHistory = "com.walletconnect.sdk.wc_jsonRpcHistoryRecord" + case pairings = "com.walletconnect.sdk.pairingSequences" +} diff --git a/Sources/Auth/Types/Aliases/Account.swift b/Sources/Auth/Types/Aliases/Account.swift new file mode 100644 index 000000000..3ab41d381 --- /dev/null +++ b/Sources/Auth/Types/Aliases/Account.swift @@ -0,0 +1,4 @@ +import WalletConnectUtils +import Foundation + +public typealias Account = WalletConnectUtils.Account diff --git a/Sources/Auth/Types/AppMetadata.swift b/Sources/Auth/Types/Aliases/AppMetadata.swift similarity index 100% rename from Sources/Auth/Types/AppMetadata.swift rename to Sources/Auth/Types/Aliases/AppMetadata.swift diff --git a/Sources/Auth/Types/Aliases/RPCID.swift b/Sources/Auth/Types/Aliases/RPCID.swift new file mode 100644 index 000000000..51cb31cdd --- /dev/null +++ b/Sources/Auth/Types/Aliases/RPCID.swift @@ -0,0 +1,4 @@ +import Foundation +import JSONRPC + +public typealias RPCID = JSONRPC.RPCID diff --git a/Sources/Auth/Types/WalletConnectURI.swift b/Sources/Auth/Types/Aliases/WalletConnectURI.swift similarity index 100% rename from Sources/Auth/Types/WalletConnectURI.swift rename to Sources/Auth/Types/Aliases/WalletConnectURI.swift diff --git a/Sources/Auth/Types/Cacao/CacaoPayload.swift b/Sources/Auth/Types/Cacao/CacaoPayload.swift index c339bd18d..1a71050c7 100644 --- a/Sources/Auth/Types/Cacao/CacaoPayload.swift +++ b/Sources/Auth/Types/Cacao/CacaoPayload.swift @@ -4,12 +4,26 @@ struct CacaoPayload: Codable, Equatable { let iss: String let domain: String let aud: String - let version: String + let version: Int let nonce: String let iat: String - let nbf: String - let exp: String - let statement: String - let requestId: String - let resources: String + let nbf: String? + let exp: String? + let statement: String? + let requestId: String? + let resources: [String]? + + init(params: AuthPayload, didpkh: DIDPKH) { + self.iss = didpkh.iss + self.domain = params.domain + self.aud = params.aud + self.version = 1 + self.nonce = params.nonce + self.iat = params.iat + self.nbf = params.nbf + self.exp = params.exp + self.statement = params.statement + self.requestId = params.requestId + self.resources = params.resources + } } diff --git a/Sources/Auth/Types/Cacao/CacaoSignature.swift b/Sources/Auth/Types/Cacao/CacaoSignature.swift index 97c04c142..b34ee1a40 100644 --- a/Sources/Auth/Types/Cacao/CacaoSignature.swift +++ b/Sources/Auth/Types/Cacao/CacaoSignature.swift @@ -1,7 +1,13 @@ import Foundation -struct CacaoSignature: Codable, Equatable { +public struct CacaoSignature: Codable, Equatable { let t: String let s: String - let m: String + let m: String? + + public init(t: String, s: String, m: String? = nil) { + self.t = t + self.s = s + self.m = m + } } diff --git a/Sources/Auth/Types/Errors/AuthError.swift b/Sources/Auth/Types/Errors/AuthError.swift new file mode 100644 index 000000000..f5b52b21a --- /dev/null +++ b/Sources/Auth/Types/Errors/AuthError.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum AuthError: Codable, Equatable, Error { + case userRejeted + case malformedResponseParams + case malformedRequestParams + case messageCompromised + case signatureVerificationFailed +} + +extension AuthError: Reason { + + init?(code: Int) { + switch code { + case Self.userRejeted.code: + self = .userRejeted + case Self.malformedResponseParams.code: + self = .malformedResponseParams + case Self.malformedRequestParams.code: + self = .malformedRequestParams + case Self.messageCompromised.code: + self = .messageCompromised + case Self.signatureVerificationFailed.code: + self = .signatureVerificationFailed + default: + return nil + } + } + + public var code: Int { + switch self { + case .userRejeted: + return 14001 + case .malformedResponseParams: + return 12001 + case .malformedRequestParams: + return 12002 + case .messageCompromised: + return 12003 + case .signatureVerificationFailed: + return 12004 + } + } + + public var message: String { + switch self { + case .userRejeted: + return "Auth request rejected by user" + case .malformedResponseParams: + return "Response params malformed" + case .malformedRequestParams: + return "Request params malformed" + case .messageCompromised: + return "Original message compromised" + case .signatureVerificationFailed: + return "Message verification failed" + } + } +} diff --git a/Sources/Auth/Types/Errors/Reason.swift b/Sources/Auth/Types/Errors/Reason.swift new file mode 100644 index 000000000..bbf491fdd --- /dev/null +++ b/Sources/Auth/Types/Errors/Reason.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol Reason { + var code: Int { get } + var message: String { get } +} + diff --git a/Sources/Auth/Types/ProtocolRPCParams/AuthRequestParams.swift b/Sources/Auth/Types/ProtocolRPCParams/AuthRequestParams.swift index c39ba3489..46feee75f 100644 --- a/Sources/Auth/Types/ProtocolRPCParams/AuthRequestParams.swift +++ b/Sources/Auth/Types/ProtocolRPCParams/AuthRequestParams.swift @@ -1,6 +1,7 @@ import Foundation import WalletConnectUtils +/// wc_authRequest RPC method request param struct AuthRequestParams: Codable, Equatable { let requester: Requester let payloadParams: AuthPayload diff --git a/Sources/Auth/Types/ProtocolRPCParams/AuthResponseParams.swift b/Sources/Auth/Types/ProtocolRPCParams/AuthResponseParams.swift index a0d64b152..2dab1477b 100644 --- a/Sources/Auth/Types/ProtocolRPCParams/AuthResponseParams.swift +++ b/Sources/Auth/Types/ProtocolRPCParams/AuthResponseParams.swift @@ -1,6 +1,7 @@ import Foundation import WalletConnectUtils +/// wc_authRequest RPC method respond param struct AuthResponseParams: Codable, Equatable { let header: CacaoHeader let payload: CacaoPayload diff --git a/Sources/Auth/Types/Public/AuthRequest.swift b/Sources/Auth/Types/Public/AuthRequest.swift new file mode 100644 index 000000000..f431ccb4d --- /dev/null +++ b/Sources/Auth/Types/Public/AuthRequest.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct AuthRequest: Equatable, Codable { + public let id: RPCID + /// EIP-4361: Sign-In with Ethereum message + public let message: String +} diff --git a/Sources/Auth/Types/RequestParams.swift b/Sources/Auth/Types/RequestParams.swift index 24e7afab9..a3f456add 100644 --- a/Sources/Auth/Types/RequestParams.swift +++ b/Sources/Auth/Types/RequestParams.swift @@ -1,6 +1,6 @@ import Foundation -struct RequestParams { +public struct RequestParams { let domain: String let chainId: String let nonce: String diff --git a/Sources/Auth/Types/RespondParams.swift b/Sources/Auth/Types/RespondParams.swift index fcd9b3293..f7c4a4af8 100644 --- a/Sources/Auth/Types/RespondParams.swift +++ b/Sources/Auth/Types/RespondParams.swift @@ -1,7 +1,11 @@ import Foundation -struct RespondParams { - let id: Int64 - let topic: String +public struct RespondParams: Equatable { + let id: RPCID let signature: CacaoSignature + + public init(id: RPCID, signature: CacaoSignature) { + self.id = id + self.signature = signature + } } diff --git a/Sources/Chat/NetworkingInteractor.swift b/Sources/Chat/NetworkingInteractor.swift index 946a79dbd..c8bf6176c 100644 --- a/Sources/Chat/NetworkingInteractor.swift +++ b/Sources/Chat/NetworkingInteractor.swift @@ -39,6 +39,8 @@ class NetworkingInteractor: NetworkInteracting { private let responsePublisherSubject = PassthroughSubject() var socketConnectionStatusPublisher: AnyPublisher + private var publishers = Set() + init(relayClient: RelayClient, serializer: Serializing, logger: ConsoleLogging, @@ -49,9 +51,10 @@ class NetworkingInteractor: NetworkInteracting { self.jsonRpcHistory = jsonRpcHistory self.logger = logger self.socketConnectionStatusPublisher = relayClient.socketConnectionStatusPublisher - relayClient.onMessage = { [unowned self] topic, message in + + relayClient.messagePublisher.sink { [unowned self] (topic, message) in manageSubscription(topic, message) - } + }.store(in: &publishers) } func request(_ request: JSONRPCRequest, topic: String, envelopeType: Envelope.EnvelopeType) async throws { diff --git a/Sources/JSONRPC/RPCResponse.swift b/Sources/JSONRPC/RPCResponse.swift index e0e60f06f..ad6ba9ca6 100644 --- a/Sources/JSONRPC/RPCResponse.swift +++ b/Sources/JSONRPC/RPCResponse.swift @@ -47,6 +47,10 @@ public struct RPCResponse: Equatable { self.init(id: id, outcome: .success(AnyCodable(result))) } + public init(id: RPCID?, error: JSONRPCError) { + self.init(id: id, outcome: .failure(error)) + } + public init(id: Int64, error: JSONRPCError) { self.init(id: RPCID(id), outcome: .failure(error)) } diff --git a/Sources/WalletConnectRelay/Relay.swift b/Sources/WalletConnectRelay/Relay.swift new file mode 100644 index 000000000..f6f823cf5 --- /dev/null +++ b/Sources/WalletConnectRelay/Relay.swift @@ -0,0 +1,34 @@ +import Foundation + +public class Relay { + + public static var instance: RelayClient = { + guard let config = Relay.config else { + fatalError("Error - you must call Relay.configure(_:) before accessing the shared instance.") + } + return RelayClient( + relayHost: config.relayHost, + projectId: config.projectId, + socketFactory: config.socketFactory, + socketConnectionType: config.socketConnectionType + ) + }() + + private static var config: Config? + + private init() { } + + static public func configure( + relayHost: String = "relay.walletconnect.com", + projectId: String, + socketFactory: WebSocketFactory, + socketConnectionType: SocketConnectionType = .automatic + ) { + Relay.config = Relay.Config( + relayHost: relayHost, + projectId: projectId, + socketFactory: socketFactory, + socketConnectionType: socketConnectionType + ) + } +} diff --git a/Sources/WalletConnectRelay/RelayClient.swift b/Sources/WalletConnectRelay/RelayClient.swift index 6be813de9..0c18c01d7 100644 --- a/Sources/WalletConnectRelay/RelayClient.swift +++ b/Sources/WalletConnectRelay/RelayClient.swift @@ -17,14 +17,18 @@ public final class RelayClient { static let historyIdentifier = "com.walletconnect.sdk.relayer_client.subscription_json_rpc_record" - public var onMessage: ((String, String) -> Void)? - let defaultTtl = 6*Time.hour var subscriptions: [String: String] = [:] + public var messagePublisher: AnyPublisher<(topic: String, message: String), Never> { + messagePublisherSubject.eraseToAnyPublisher() + } + public var socketConnectionStatusPublisher: AnyPublisher { socketConnectionStatusPublisherSubject.eraseToAnyPublisher() } + + private let messagePublisherSubject = PassthroughSubject<(topic: String, message: String), Never>() private let socketConnectionStatusPublisherSubject = PassthroughSubject() private let subscriptionResponsePublisherSubject = PassthroughSubject<(RPCID?, String), Never>() @@ -42,6 +46,8 @@ public final class RelayClient { private let concurrentQueue = DispatchQueue(label: "com.walletconnect.sdk.relay_client", attributes: .concurrent) + // MARK: - Initialization + init( dispatcher: Dispatching, logger: ConsoleLogging, @@ -227,7 +233,7 @@ public final class RelayClient { do { try rpcHistory.set(request, forTopic: params.data.topic, emmitedBy: .remote) try acknowledgeRequest(request) - onMessage?(params.data.topic, params.data.message) + messagePublisherSubject.send((params.data.topic, params.data.message)) } catch { logger.error("[RelayClient] RPC History 'set()' error: \(error)") } diff --git a/Sources/WalletConnectRelay/RelayConfig.swift b/Sources/WalletConnectRelay/RelayConfig.swift new file mode 100644 index 000000000..b716ed9ca --- /dev/null +++ b/Sources/WalletConnectRelay/RelayConfig.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Relay { + struct Config { + let relayHost: String + let projectId: String + let socketFactory: WebSocketFactory + let socketConnectionType: SocketConnectionType + } +} diff --git a/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift b/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift index 20eab6208..5a5cec5f6 100644 --- a/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift +++ b/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift @@ -36,6 +36,7 @@ class AutomaticSocketConnectionHandler: SocketConnectionHandler { } appStateObserver.onWillEnterForeground = { [unowned self] in + backgroundTaskRegistrar.invalidate() socket.connect() } } diff --git a/Sources/WalletConnectRelay/SocketConnectionHandler/BackgroundTaskRegistering.swift b/Sources/WalletConnectRelay/SocketConnectionHandler/BackgroundTaskRegistering.swift index 836283339..7b08c94a6 100644 --- a/Sources/WalletConnectRelay/SocketConnectionHandler/BackgroundTaskRegistering.swift +++ b/Sources/WalletConnectRelay/SocketConnectionHandler/BackgroundTaskRegistering.swift @@ -5,6 +5,7 @@ import UIKit protocol BackgroundTaskRegistering { func register(name: String, completion: @escaping () -> Void) + func invalidate() } class BackgroundTaskRegistrar: BackgroundTaskRegistering { @@ -20,6 +21,15 @@ class BackgroundTaskRegistrar: BackgroundTaskRegistering { backgroundTaskID = .invalid completion() } +#endif + } + + func invalidate() { +#if os(iOS) + if backgroundTaskID != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTaskID) + backgroundTaskID = .invalid + } #endif } } diff --git a/Sources/WalletConnectRouter/Router.m b/Sources/WalletConnectRouter/Router.m new file mode 100644 index 000000000..9010aa7e6 --- /dev/null +++ b/Sources/WalletConnectRouter/Router.m @@ -0,0 +1,26 @@ +#import +#import "Router.h" + +@import UIKit; +@import ObjectiveC.runtime; + +@interface UISystemNavigationAction : NSObject +@property(nonatomic, readonly, nonnull) NSArray* destinations; +-(BOOL)sendResponseForDestination:(NSUInteger)destination; +@end + +@implementation Router + ++ (void)goBack { + Ivar sysNavIvar = class_getInstanceVariable(UIApplication.class, "_systemNavigationAction"); + UIApplication* app = UIApplication.sharedApplication; + UISystemNavigationAction* action = object_getIvar(app, sysNavIvar); + if (!action) { + return; + } + NSUInteger destination = action.destinations.firstObject.unsignedIntegerValue; + [action sendResponseForDestination:destination]; +} + +@end + diff --git a/Sources/WalletConnectRouter/include/Router.h b/Sources/WalletConnectRouter/include/Router.h new file mode 100644 index 000000000..222fbe4cc --- /dev/null +++ b/Sources/WalletConnectRouter/include/Router.h @@ -0,0 +1,5 @@ +#import + +@interface Router: NSObject ++ (void)goBack; +@end diff --git a/Sources/WalletConnectRouter/include/module.modulemap b/Sources/WalletConnectRouter/include/module.modulemap new file mode 100644 index 000000000..036065d03 --- /dev/null +++ b/Sources/WalletConnectRouter/include/module.modulemap @@ -0,0 +1,3 @@ +module WalletConnectRouter { + header "Router.h" +} diff --git a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift index 66c08959d..229e2e698 100644 --- a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift @@ -280,7 +280,7 @@ private extension ApproveEngine { func handleSessionProposeRequest(payload: WCRequestSubscriptionPayload, proposal: SessionType.ProposeParams) throws { logger.debug("Received Session Proposal") - do { try Namespace.validate(proposal.requiredNamespaces) } catch { throw Errors.respondError(payload: payload, reason: .invalidUpdateNamespaceRequest) } + do { try Namespace.validate(proposal.requiredNamespaces) } catch { throw Errors.respondError(payload: payload, reason: .invalidUpdateRequest) } proposalPayloadsStore.set(payload, forKey: proposal.proposer.publicKey) onSessionProposal?(proposal.publicRepresentation()) } @@ -290,7 +290,7 @@ private extension ApproveEngine { logger.debug("Did receive session settle request") guard let proposedNamespaces = settlingProposal?.requiredNamespaces - else { throw Errors.respondError(payload: payload, reason: .invalidUpdateNamespaceRequest) } + else { throw Errors.respondError(payload: payload, reason: .invalidUpdateRequest) } settlingProposal = nil diff --git a/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift b/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift index 5b619f8f6..c535d7ce3 100644 --- a/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift @@ -153,7 +153,7 @@ private extension SessionEngine { func onSessionDelete(_ payload: WCRequestSubscriptionPayload, deleteParams: SessionType.DeleteParams) throws { let topic = payload.topic guard sessionStore.hasSession(forTopic: topic) else { - throw Errors.respondError(payload: payload, reason: .noContextWithTopic(context: .session, topic: topic)) + throw Errors.respondError(payload: payload, reason: .noSessionForTopic) } sessionStore.delete(topic: topic) networkingInteractor.unsubscribe(topic: topic) @@ -172,11 +172,11 @@ private extension SessionEngine { chainId: payloadParams.chainId) guard let session = sessionStore.getSession(forTopic: topic) else { - throw Errors.respondError(payload: payload, reason: .noContextWithTopic(context: .session, topic: topic)) + throw Errors.respondError(payload: payload, reason: .noSessionForTopic) } let chain = request.chainId guard session.hasNamespace(for: chain) else { - throw Errors.respondError(payload: payload, reason: .unauthorizedTargetChain(chain.absoluteString)) + throw Errors.respondError(payload: payload, reason: .unauthorizedChain) } guard session.hasPermission(forMethod: request.method, onChain: chain) else { throw Errors.respondError(payload: payload, reason: .unauthorizedMethod(request.method)) @@ -192,7 +192,7 @@ private extension SessionEngine { let event = eventParams.event let topic = payload.topic guard let session = sessionStore.getSession(forTopic: topic) else { - throw Errors.respondError(payload: payload, reason: .noContextWithTopic(context: .session, topic: payload.topic)) + throw Errors.respondError(payload: payload, reason: .noSessionForTopic) } guard session.peerIsController, diff --git a/Sources/WalletConnectSign/Engine/NonController/NonControllerSessionStateMachine.swift b/Sources/WalletConnectSign/Engine/NonController/NonControllerSessionStateMachine.swift index 363eb5d88..0e4e22f66 100644 --- a/Sources/WalletConnectSign/Engine/NonController/NonControllerSessionStateMachine.swift +++ b/Sources/WalletConnectSign/Engine/NonController/NonControllerSessionStateMachine.swift @@ -62,18 +62,18 @@ final class NonControllerSessionStateMachine { do { try Namespace.validate(updateParams.namespaces) } catch { - throw Errors.respondError(payload: payload, reason: .invalidUpdateNamespaceRequest) + throw Errors.respondError(payload: payload, reason: .invalidUpdateRequest) } guard var session = sessionStore.getSession(forTopic: payload.topic) else { - throw Errors.respondError(payload: payload, reason: .noContextWithTopic(context: .session, topic: payload.topic)) + throw Errors.respondError(payload: payload, reason: .noSessionForTopic) } guard session.peerIsController else { - throw Errors.respondError(payload: payload, reason: .unauthorizedUpdateNamespacesRequest) + throw Errors.respondError(payload: payload, reason: .unauthorizedUpdateRequest) } do { try session.updateNamespaces(updateParams.namespaces, timestamp: payload.timestamp) } catch { - throw Errors.respondError(payload: payload, reason: .invalidUpdateNamespaceRequest) + throw Errors.respondError(payload: payload, reason: .invalidUpdateRequest) } sessionStore.setSession(session) networkingInteractor.respondSuccess(for: payload) @@ -83,15 +83,15 @@ final class NonControllerSessionStateMachine { private func onSessionUpdateExpiry(_ payload: WCRequestSubscriptionPayload, updateExpiryParams: SessionType.UpdateExpiryParams) throws { let topic = payload.topic guard var session = sessionStore.getSession(forTopic: topic) else { - throw Errors.respondError(payload: payload, reason: .noContextWithTopic(context: .session, topic: topic)) + throw Errors.respondError(payload: payload, reason: .noSessionForTopic) } guard session.peerIsController else { - throw Errors.respondError(payload: payload, reason: .unauthorizedUpdateExpiryRequest) + throw Errors.respondError(payload: payload, reason: .unauthorizedExtendRequest) } do { try session.updateExpiry(to: updateExpiryParams.expiry) } catch { - throw Errors.respondError(payload: payload, reason: .invalidUpdateExpiryRequest) + throw Errors.respondError(payload: payload, reason: .invalidExtendRequest) } sessionStore.setSession(session) networkingInteractor.respondSuccess(for: payload) diff --git a/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift b/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift index 05d7e8ad9..3389771b3 100644 --- a/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift +++ b/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift @@ -29,7 +29,7 @@ extension NetworkInteracting { class NetworkInteractor: NetworkInteracting { - private var publishers = [AnyCancellable]() + private var publishers = Set() private var relayClient: NetworkRelaying private let serializer: Serializing @@ -183,9 +183,10 @@ class NetworkInteractor: NetworkInteracting { } }.store(in: &publishers) - relayClient.onMessage = { [unowned self] topic, message in - manageSubscription(topic, message) + relayClient.messagePublisher.sink { [weak self] (topic, message) in + self?.manageSubscription(topic, message) } + .store(in: &publishers) } private func manageSubscription(_ topic: String, _ encodedEnvelope: String) { diff --git a/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift b/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift index 2ea62420d..5574cf826 100644 --- a/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift +++ b/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift @@ -5,7 +5,7 @@ import Combine extension RelayClient: NetworkRelaying {} protocol NetworkRelaying { - var onMessage: ((_ topic: String, _ message: String) -> Void)? {get set} + var messagePublisher: AnyPublisher<(topic: String, message: String), Never> { get } var socketConnectionStatusPublisher: AnyPublisher { get } func connect() throws func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) throws diff --git a/Sources/WalletConnectSign/RejectionReason.swift b/Sources/WalletConnectSign/RejectionReason.swift index cee66c716..338cffe4a 100644 --- a/Sources/WalletConnectSign/RejectionReason.swift +++ b/Sources/WalletConnectSign/RejectionReason.swift @@ -2,20 +2,23 @@ import Foundation /// https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-25.md public enum RejectionReason { - case disapprovedChains - case disapprovedMethods - case disapprovedEventTypes + case userRejected + case userRejectedChains + case userRejectedMethods + case userRejectedEvents } internal extension RejectionReason { func internalRepresentation() -> ReasonCode { switch self { - case .disapprovedChains: - return ReasonCode.disapprovedChains - case .disapprovedMethods: - return ReasonCode.disapprovedMethods - case .disapprovedEventTypes: - return ReasonCode.disapprovedEventTypes + case .userRejected: + return ReasonCode.userRejected + case .userRejectedChains: + return ReasonCode.userRejectedChains + case .userRejectedMethods: + return ReasonCode.userRejectedMethods + case .userRejectedEvents: + return ReasonCode.userRejectedEvents } } } diff --git a/Sources/WalletConnectSign/Sign/Sign.swift b/Sources/WalletConnectSign/Sign/Sign.swift index f00ae09be..75a384af7 100644 --- a/Sources/WalletConnectSign/Sign/Sign.swift +++ b/Sources/WalletConnectSign/Sign/Sign.swift @@ -7,272 +7,22 @@ public typealias Account = WalletConnectUtils.Account public typealias Blockchain = WalletConnectUtils.Blockchain public class Sign { - public static let instance = Sign() - private static var config: Config? - private let client: SignClient - private let relayClient: RelayClient - - private init() { - guard let config = Sign.config else { - fatalError("Error - you must call configure(_:) before accessing the shared instance.") + public static var instance: SignClient = { + guard let metadata = Sign.metadata else { + fatalError("Error - you must call Sign.configure(_:) before accessing the shared instance.") } - relayClient = RelayClient( - relayHost: "relay.walletconnect.com", - projectId: config.projectId, - socketFactory: config.socketFactory, - socketConnectionType: config.socketConnectionType - ) - client = SignClientFactory.create( - metadata: config.metadata, - relayClient: relayClient - ) - client.delegate = self - } - - static public func configure( - metadata: AppMetadata, - projectId: String, - socketFactory: WebSocketFactory, - socketConnectionType: SocketConnectionType = .automatic - ) { - Sign.config = Sign.Config( + return SignClientFactory.create( metadata: metadata, - projectId: projectId, - socketFactory: socketFactory, - socketConnectionType: socketConnectionType + relayClient: Relay.instance ) - } - - var sessionProposalPublisherSubject = PassthroughSubject() - public var sessionProposalPublisher: AnyPublisher { - sessionProposalPublisherSubject.eraseToAnyPublisher() - } - - var sessionRequestPublisherSubject = PassthroughSubject() - public var sessionRequestPublisher: AnyPublisher { - sessionRequestPublisherSubject.eraseToAnyPublisher() - } - - var socketConnectionStatusPublisherSubject = PassthroughSubject() - public var socketConnectionStatusPublisher: AnyPublisher { - socketConnectionStatusPublisherSubject.eraseToAnyPublisher() - } - - var sessionSettlePublisherSubject = PassthroughSubject() - public var sessionSettlePublisher: AnyPublisher { - sessionSettlePublisherSubject.eraseToAnyPublisher() - } - - var sessionDeletePublisherSubject = PassthroughSubject<(String, Reason), Never>() - public var sessionDeletePublisher: AnyPublisher<(String, Reason), Never> { - sessionDeletePublisherSubject.eraseToAnyPublisher() - } - - var sessionResponsePublisherSubject = PassthroughSubject() - public var sessionResponsePublisher: AnyPublisher { - sessionResponsePublisherSubject.eraseToAnyPublisher() - } - - var sessionRejectionPublisherSubject = PassthroughSubject<(Session.Proposal, Reason), Never>() - public var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { - sessionRejectionPublisherSubject.eraseToAnyPublisher() - } - - var sessionUpdatePublisherSubject = PassthroughSubject<(sessionTopic: String, namespaces: [String: SessionNamespace]), Never>() - public var sessionUpdatePublisher: AnyPublisher<(sessionTopic: String, namespaces: [String: SessionNamespace]), Never> { - sessionUpdatePublisherSubject.eraseToAnyPublisher() - } - - var sessionEventPublisherSubject = PassthroughSubject<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never>() - public var sessionEventPublisher: AnyPublisher<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never> { - sessionEventPublisherSubject.eraseToAnyPublisher() - } - - var sessionExtendPublisherSubject = PassthroughSubject<(sessionTopic: String, date: Date), Never>() - public var sessionExtendPublisher: AnyPublisher<(sessionTopic: String, date: Date), Never> { - sessionExtendPublisherSubject.eraseToAnyPublisher() - } -} - -extension Sign: SignClientDelegate { - - public func didReceive(sessionProposal: Session.Proposal) { - sessionProposalPublisherSubject.send(sessionProposal) - } - - public func didReceive(sessionRequest: Request) { - sessionRequestPublisherSubject.send(sessionRequest) - } - - public func didReceive(sessionResponse: Response) { - sessionResponsePublisherSubject.send(sessionResponse) - } - - public func didDelete(sessionTopic: String, reason: Reason) { - sessionDeletePublisherSubject.send((sessionTopic, reason)) - } - - public func didUpdate(sessionTopic: String, namespaces: [String: SessionNamespace]) { - sessionUpdatePublisherSubject.send((sessionTopic, namespaces)) - } - - public func didExtend(sessionTopic: String, to date: Date) { - sessionExtendPublisherSubject.send((sessionTopic, date)) - } - - public func didSettle(session: Session) { - sessionSettlePublisherSubject.send(session) - } - - public func didReceive(event: Session.Event, sessionTopic: String, chainId: Blockchain?) { - sessionEventPublisherSubject.send((event, sessionTopic, chainId)) - } + }() - public func didReject(proposal: Session.Proposal, reason: Reason) { - sessionRejectionPublisherSubject.send((proposal, reason)) - } + private static var metadata: AppMetadata? - public func didChangeSocketConnectionStatus(_ status: SocketConnectionStatus) { - socketConnectionStatusPublisherSubject.send(status) - } -} - -extension Sign { - - /// For the Proposer to propose a session to a responder. - /// Function will create pending pairing sequence or propose a session on existing pairing. When responder client approves pairing, session is be proposed automatically by your client. - /// - Parameter sessionPermissions: The session permissions the responder will be requested for. - /// - Parameter topic: Optional parameter - use it if you already have an established pairing with peer client. - /// - Returns: Pairing URI that should be shared with responder out of bound. Common way is to present it as a QR code. Pairing URI will be nil if you are going to establish a session on existing Pairing and `topic` function parameter was provided. - public func connect(requiredNamespaces: [String: ProposalNamespace], topic: String? = nil) async throws -> String? { - try await client.connect(requiredNamespaces: requiredNamespaces, topic: topic) - } - - /// For responder to receive a session proposal from a proposer - /// Responder should call this function in order to accept peer's pairing proposal and be able to subscribe for future session proposals. - /// - Parameter uri: Pairing URI that is commonly presented as a QR code by a dapp. - /// - /// Should Error: - /// - When URI is invalid format or missing params - /// - When topic is already in use - public func pair(uri: String) async throws { - try await client.pair(uri: uri) - } - - /// For the responder to approve a session proposal. - /// - Parameters: - /// - proposalId: Session Proposal Public key received from peer client in a WalletConnect delegate function: `didReceive(sessionProposal: Session.Proposal)` - /// - accounts: A Set of accounts that the dapp will be allowed to request methods executions on. - /// - methods: A Set of methods that the dapp will be allowed to request. - /// - events: A Set of events - public func approve(proposalId: String, namespaces: [String: SessionNamespace]) async throws { - try await client.approve(proposalId: proposalId, namespaces: namespaces) - } - - /// For the responder to reject a session proposal. - /// - Parameters: - /// - proposalId: Session Proposal Public key received from peer client in a WalletConnect delegate. - /// - reason: Reason why the session proposal was rejected. Conforms to CAIP25. - public func reject(proposalId: String, reason: RejectionReason) async throws { - try await client.reject(proposalId: proposalId, reason: reason) - } - - /// For the responder to update session methods - /// - Parameters: - /// - topic: Topic of the session that is intended to be updated. - /// - methods: Sets of methods that will replace existing ones. - public func update(topic: String, namespaces: [String: SessionNamespace]) async throws { - try await client.update(topic: topic, namespaces: namespaces) - } - - /// For controller to update expiry of a session - /// - Parameters: - /// - topic: Topic of the Session, it can be a pairing or a session topic. - /// - ttl: Time in seconds that a target session is expected to be extended for. Must be greater than current time to expire and than 7 days - public func extend(topic: String) async throws { - try await client.extend(topic: topic) - } - - /// For the proposer to send JSON-RPC requests to responding peer. - /// - Parameters: - /// - params: Parameters defining request and related session - public func request(params: Request) async throws { - try await client.request(params: params) - } - - /// For the responder to respond on pending peer's session JSON-RPC Request - /// - Parameters: - /// - topic: Topic of the session for which the request was received. - /// - response: Your JSON RPC response or an error. - public func respond(topic: String, response: JsonRpcResult) async throws { - try await client.respond(topic: topic, response: response) - } - - /// Ping method allows to check if client's peer is online and is subscribing for your sequence topic - /// - /// Should Error: - /// - When the session topic is not found - /// - When the response is neither result or error - /// - When the peer fails to respond within timeout - /// - /// - Parameters: - /// - topic: Topic of the sequence, it can be a pairing or a session topic. - /// - completion: Result will be success on response or error on timeout. -- TODO: timeout - public func ping(topic: String, completion: @escaping ((Result) -> Void)) { - client.ping(topic: topic, completion: completion) - } - - /// - Parameters: - /// - topic: Session topic - /// - params: Event Parameters - /// - completion: calls a handler upon completion - public func emit(topic: String, event: Session.Event, chainId: Blockchain) async throws { - try await client.emit(topic: topic, event: event, chainId: chainId) - } - - /// - Parameters: - /// - topic: Session topic that you want to delete - public func disconnect(topic: String) async throws { - try await client.disconnect(topic: topic) - } - - /// - Returns: All sessions - public func getSessions() -> [Session] { - client.getSessions() - } - - /// - Returns: All settled pairings that are active - public func getSettledPairings() -> [Pairing] { - client.getSettledPairings() - } - - /// - Returns: Pending requests received with wc_sessionRequest - /// - Parameter topic: topic representing session for which you want to get pending requests. If nil, you will receive pending requests for all active sessions. - public func getPendingRequests(topic: String? = nil) -> [Request] { - client.getPendingRequests(topic: topic) - } - - /// - Parameter id: id of a wc_sessionRequest jsonrpc request - /// - Returns: json rpc record object for given id or nil if record for give id does not exits - public func getSessionRequestRecord(id: Int64) -> WalletConnectUtils.JsonRpcRecord? { - client.getSessionRequestRecord(id: id) - } - - public func connect() throws { - try relayClient.connect() - } - - public func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) throws { - try relayClient.disconnect(closeCode: closeCode) - } + private init() { } -#if DEBUG - /// Delete all stored data sach as: pairings, sessions, keys - /// - /// - Note: Doesn't unsubscribe from topics - public func cleanup() throws { - try client.cleanup() + static public func configure(metadata: AppMetadata) { + Sign.metadata = metadata } -#endif } diff --git a/Sources/WalletConnectSign/Sign/SignClient.swift b/Sources/WalletConnectSign/Sign/SignClient.swift index 1dc88cae2..d8fc4d288 100644 --- a/Sources/WalletConnectSign/Sign/SignClient.swift +++ b/Sources/WalletConnectSign/Sign/SignClient.swift @@ -3,9 +3,6 @@ import WalletConnectRelay import WalletConnectUtils import WalletConnectKMS import Combine -#if os(iOS) -import UIKit -#endif /// An Object that expose public API to provide interactions with WalletConnect SDK /// @@ -15,14 +12,83 @@ import UIKit /// let metadata = AppMetadata(name: String?, description: String?, url: String?, icons: [String]?) /// let client = SignClient(metadata: AppMetadata, projectId: String, relayHost: String) /// ``` -/// -/// - Parameters: -/// - delegate: The object that acts as the delegate of WalletConnect Client -/// - logger: An object for logging messages public final class SignClient { - public weak var delegate: SignClientDelegate? + /// Tells the delegate that session proposal has been received. + /// + /// Function is executed on responder client only + public var sessionProposalPublisher: AnyPublisher { + sessionProposalPublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that session payload request has been received + /// + /// In most cases that function is supposed to be called on wallet client. + /// - Parameters: + /// - sessionRequest: Object containing request received from peer client. + public var sessionRequestPublisher: AnyPublisher { + sessionRequestPublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that client has connected WebSocket + public var socketConnectionStatusPublisher: AnyPublisher { + socketConnectionStatusPublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that the client has settled a session. + /// + /// Function is executed on proposer and responder client when both communicating peers have successfully established a session. + public var sessionSettlePublisher: AnyPublisher { + sessionSettlePublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that the peer client has terminated the session. + /// + /// Function can be executed on any type of the client. + public var sessionDeletePublisher: AnyPublisher<(String, Reason), Never> { + sessionDeletePublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that session payload response has been received + /// + /// In most cases that function is supposed to be called on dApp client. + /// - Parameters: + /// - sessionResponse: Object containing response received from peer client. + public var sessionResponsePublisher: AnyPublisher { + sessionResponsePublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that peer client has rejected a session proposal. + /// + /// Function will be executed on proposer client only. + public var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { + sessionRejectionPublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that methods has been updated in session + /// + /// Function is executed on controller and non-controller client when both communicating peers have successfully updated methods requested by the controller client. + public var sessionUpdatePublisher: AnyPublisher<(sessionTopic: String, namespaces: [String: SessionNamespace]), Never> { + sessionUpdatePublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that event has been received. + public var sessionEventPublisher: AnyPublisher<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never> { + sessionEventPublisherSubject.eraseToAnyPublisher() + } + + /// Tells the delegate that session expiry has been updated + /// + /// Function will be executed on controller and non-controller clients. + public var sessionExtendPublisher: AnyPublisher<(sessionTopic: String, date: Date), Never> { + sessionExtendPublisherSubject.eraseToAnyPublisher() + } + + /// An object for logging messages public let logger: ConsoleLogging + + // MARK: - Private properties + private let relayClient: RelayClient private let pairingEngine: PairingEngine private let pairEngine: PairEngine @@ -32,7 +98,19 @@ public final class SignClient { private let controllerSessionStateMachine: ControllerSessionStateMachine private let history: JsonRpcHistory private let cleanupService: CleanupService - private var publishers = [AnyCancellable]() + + private let sessionProposalPublisherSubject = PassthroughSubject() + private let sessionRequestPublisherSubject = PassthroughSubject() + private let socketConnectionStatusPublisherSubject = PassthroughSubject() + private let sessionSettlePublisherSubject = PassthroughSubject() + private let sessionDeletePublisherSubject = PassthroughSubject<(String, Reason), Never>() + private let sessionResponsePublisherSubject = PassthroughSubject() + private let sessionRejectionPublisherSubject = PassthroughSubject<(Session.Proposal, Reason), Never>() + private let sessionUpdatePublisherSubject = PassthroughSubject<(sessionTopic: String, namespaces: [String: SessionNamespace]), Never>() + private let sessionEventPublisherSubject = PassthroughSubject<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never>() + private let sessionExtendPublisherSubject = PassthroughSubject<(sessionTopic: String, date: Date), Never>() + + private var publishers = Set() // MARK: - Initialization @@ -241,48 +319,48 @@ public final class SignClient { private func setUpEnginesCallbacks() { approveEngine.onSessionProposal = { [unowned self] proposal in - delegate?.didReceive(sessionProposal: proposal) + sessionProposalPublisherSubject.send(proposal) } approveEngine.onSessionRejected = { [unowned self] proposal, reason in - delegate?.didReject(proposal: proposal, reason: reason.publicRepresentation()) + sessionRejectionPublisherSubject.send((proposal, reason.publicRepresentation())) } approveEngine.onSessionSettle = { [unowned self] settledSession in - delegate?.didSettle(session: settledSession) + sessionSettlePublisherSubject.send(settledSession) } sessionEngine.onSessionRequest = { [unowned self] sessionRequest in - delegate?.didReceive(sessionRequest: sessionRequest) + sessionRequestPublisherSubject.send(sessionRequest) } sessionEngine.onSessionDelete = { [unowned self] topic, reason in - delegate?.didDelete(sessionTopic: topic, reason: reason.publicRepresentation()) + sessionDeletePublisherSubject.send((topic, reason.publicRepresentation())) } controllerSessionStateMachine.onNamespacesUpdate = { [unowned self] topic, namespaces in - delegate?.didUpdate(sessionTopic: topic, namespaces: namespaces) + sessionUpdatePublisherSubject.send((topic, namespaces)) } controllerSessionStateMachine.onExtend = { [unowned self] topic, date in - delegate?.didExtend(sessionTopic: topic, to: date) + sessionExtendPublisherSubject.send((topic, date)) } nonControllerSessionStateMachine.onNamespacesUpdate = { [unowned self] topic, namespaces in - delegate?.didUpdate(sessionTopic: topic, namespaces: namespaces) + sessionUpdatePublisherSubject.send((topic, namespaces)) } nonControllerSessionStateMachine.onExtend = { [unowned self] topic, date in - delegate?.didExtend(sessionTopic: topic, to: date) + sessionExtendPublisherSubject.send((topic, date)) } sessionEngine.onEventReceived = { [unowned self] topic, event, chainId in - delegate?.didReceive(event: event, sessionTopic: topic, chainId: chainId) + sessionEventPublisherSubject.send((event, topic, chainId)) } sessionEngine.onSessionResponse = { [unowned self] response in - delegate?.didReceive(sessionResponse: response) + sessionResponsePublisherSubject.send(response) } } private func setUpConnectionObserving() { relayClient.socketConnectionStatusPublisher.sink { [weak self] status in - self?.delegate?.didChangeSocketConnectionStatus(status) + self?.socketConnectionStatusPublisherSubject.send(status) }.store(in: &publishers) } #if DEBUG - /// Delete all stored data sach as: pairings, sessions, keys + /// Delete all stored data such as: pairings, sessions, keys /// /// - Note: Doesn't unsubscribe from topics public func cleanup() throws { diff --git a/Sources/WalletConnectSign/Sign/SignClientDelegate.swift b/Sources/WalletConnectSign/Sign/SignClientDelegate.swift deleted file mode 100644 index 2a3823552..000000000 --- a/Sources/WalletConnectSign/Sign/SignClientDelegate.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import WalletConnectUtils -import WalletConnectRelay - -/// A protocol that defines methods that SignClient instance call on it's delegate to handle sequences level events -public protocol SignClientDelegate: AnyObject { - - /// Tells the delegate that session proposal has been received. - /// - /// Function is executed on responder client only - func didReceive(sessionProposal: Session.Proposal) - - /// Tells the delegate that session payload request has been received - /// - /// In most cases that function is supposed to be called on wallet client. - /// - Parameters: - /// - sessionRequest: Object containing request received from peer client. - func didReceive(sessionRequest: Request) - - /// Tells the delegate that session payload response has been received - /// - /// In most cases that function is supposed to be called on dApp client. - /// - Parameters: - /// - sessionResponse: Object containing response received from peer client. - func didReceive(sessionResponse: Response) - - /// Tells the delegate that the peer client has terminated the session. - /// - /// Function can be executed on any type of the client. - func didDelete(sessionTopic: String, reason: Reason) - - /// Tells the delegate that methods has been updated in session - /// - /// Function is executed on controller and non-controller client when both communicating peers have successfully updated methods requested by the controller client. - func didUpdate(sessionTopic: String, namespaces: [String: SessionNamespace]) - - /// Tells the delegate that session expiry has been updated - /// - /// Function will be executed on controller and non-controller clients. - func didExtend(sessionTopic: String, to date: Date) - - /// Tells the delegate that the client has settled a session. - /// - /// Function is executed on proposer and responder client when both communicating peers have successfully established a session. - func didSettle(session: Session) - - /// Tells the delegate that event has been received. - func didReceive(event: Session.Event, sessionTopic: String, chainId: Blockchain?) - - /// Tells the delegate that peer client has rejected a session proposal. - /// - /// Function will be executed on proposer client only. - func didReject(proposal: Session.Proposal, reason: Reason) - - /// Tells the delegate that client has connected WebSocket - func didChangeSocketConnectionStatus(_ status: SocketConnectionStatus) -} - -public extension SignClientDelegate { - func didReceive(event: Session.Event, sessionTopic: String, chainId: Blockchain?) {} - func didReject(proposal: Session.Proposal, reason: Reason) {} - func didReceive(sessionRequest: Request) {} - func didReceive(sessionProposal: Session.Proposal) {} - func didReceive(sessionResponse: Response) {} - func didExtend(sessionTopic: String, to date: Date) {} - func didDisconnect() {} -} diff --git a/Sources/WalletConnectSign/Sign/SignConfig.swift b/Sources/WalletConnectSign/Sign/SignConfig.swift deleted file mode 100644 index 4ba4edeaa..000000000 --- a/Sources/WalletConnectSign/Sign/SignConfig.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import WalletConnectRelay - -public extension Sign { - struct Config { - let metadata: AppMetadata - let projectId: String - let socketFactory: WebSocketFactory - let socketConnectionType: SocketConnectionType - - public init( - metadata: AppMetadata, - projectId: String, - socketFactory: WebSocketFactory, - socketConnectionType: SocketConnectionType = .automatic - ) { - self.metadata = metadata - self.projectId = projectId - self.socketFactory = socketFactory - self.socketConnectionType = socketConnectionType - } - } -} diff --git a/Sources/WalletConnectSign/Types/ReasonCode.swift b/Sources/WalletConnectSign/Types/ReasonCode.swift index 496818936..cdc4f8db5 100644 --- a/Sources/WalletConnectSign/Types/ReasonCode.swift +++ b/Sources/WalletConnectSign/Types/ReasonCode.swift @@ -1,33 +1,33 @@ -enum ReasonCode { +enum ReasonCode: Codable, Equatable { - enum Context: String { + enum Context: String, Codable { case pairing = "pairing" case session = "session" } - // 0 (Generic) - case generic(message: String) + // 1000 - (Internal) + case invalidMethod + case invalidEvent + case invalidUpdateRequest + case invalidExtendRequest + case noSessionForTopic - // 1000 (Internal) - case missingOrInvalid(String) - case invalidUpdateAccountsRequest - case invalidUpdateNamespaceRequest - case invalidUpdateExpiryRequest - case noContextWithTopic(context: Context, topic: String) - - // 3000 (Unauthorized) - case unauthorizedTargetChain(String) + // 3000 - (Unauthorized) case unauthorizedMethod(String) case unauthorizedEvent(String) - case unauthorizedUpdateAccountRequest - case unauthorizedUpdateNamespacesRequest - case unauthorizedUpdateExpiryRequest - case unauthorizedMatchingController(isController: Bool) - - // 5000 - case disapprovedChains - case disapprovedMethods - case disapprovedEventTypes + case unauthorizedUpdateRequest + case unauthorizedExtendRequest + case unauthorizedChain + + // 4001 - (EIP-1193) + case userRejectedRequest + + // 5000 - (REJECTED (CAIP-25)) + case userRejected + case userRejectedChains + case userRejectedMethods + case userRejectedEvents + case unsupportedChains case unsupportedMethods case unsupportedEvents @@ -39,25 +39,23 @@ enum ReasonCode { var code: Int { switch self { - case .generic: return 0 - case .missingOrInvalid: return 1000 - - case .invalidUpdateAccountsRequest: return 1003 - case .invalidUpdateNamespaceRequest: return 1004 - case .invalidUpdateExpiryRequest: return 1005 - case .noContextWithTopic: return 1301 + case .invalidMethod: return 1001 + case .invalidEvent: return 1002 + case .invalidUpdateRequest: return 1003 + case .invalidExtendRequest: return 1004 - case .unauthorizedTargetChain: return 3000 case .unauthorizedMethod: return 3001 case .unauthorizedEvent: return 3002 + case .unauthorizedUpdateRequest: return 3003 + case .unauthorizedExtendRequest: return 3004 + case .unauthorizedChain: return 3005 + + case .userRejectedRequest: return 4001 - case .unauthorizedUpdateAccountRequest: return 3003 - case .unauthorizedUpdateNamespacesRequest: return 3004 - case .unauthorizedUpdateExpiryRequest: return 3005 - case .unauthorizedMatchingController: return 3100 - case .disapprovedChains: return 5000 - case .disapprovedMethods: return 5001 - case .disapprovedEventTypes: return 5002 + case .userRejected: return 5000 + case .userRejectedChains: return 5001 + case .userRejectedMethods: return 5002 + case .userRejectedEvents: return 5003 case .unsupportedChains: return 5100 case .unsupportedMethods: return 5101 @@ -66,43 +64,47 @@ enum ReasonCode { case .unsupportedNamespaceKey: return 5104 case .userDisconnected: return 6000 + + case .noSessionForTopic: return 7001 } } var message: String { switch self { - case .generic(let message): - return message - case .missingOrInvalid(let name): - return "Missing or invalid \(name)" - case .invalidUpdateAccountsRequest: - return "Invalid update accounts request" - case .invalidUpdateNamespaceRequest: + case .invalidMethod: + return "Invalid Method" + case .invalidEvent: + return "Invalid Event" + case .invalidUpdateRequest: return "Invalid update namespace request" - case .invalidUpdateExpiryRequest: + case .invalidExtendRequest: return "Invalid update expiry request" - case .noContextWithTopic(let context, let topic): - return "No matching \(context) with topic: \(topic)" - case .unauthorizedTargetChain(let chainId): - return "Unauthorized target chain id requested: \(chainId)" + case .noSessionForTopic: + return "No matching session matching topic" + case .unauthorizedMethod(let method): return "Unauthorized JSON-RPC method requested: \(method)" case .unauthorizedEvent(let type): return "Unauthorized event type requested: \(type)" - case .unauthorizedUpdateAccountRequest: - return "Unauthorized update accounts request" - case .unauthorizedUpdateNamespacesRequest: - return "Unauthorized update namespaces request" - case .unauthorizedUpdateExpiryRequest: - return "Unauthorized update expiry request" - case .unauthorizedMatchingController(let isController): - return "Unauthorized: peer is also \(isController ? "" : "non-")controller" - case .disapprovedChains: + case .unauthorizedUpdateRequest: + return "Unauthorized update request" + case .unauthorizedExtendRequest: + return "Unauthorized extend request" + case .unauthorizedChain: + return "Unauthorized target chain id requested" + + case .userRejectedRequest: + return "User rejected request" + + case .userRejected: + return "User rejected" + case .userRejectedChains: return "User disapproved requested chains" - case .disapprovedMethods: + case .userRejectedMethods: return "User disapproved requested json-rpc methods" - case .disapprovedEventTypes: + case .userRejectedEvents: return "User disapproved requested event types" + case .unsupportedChains: return "Unsupported or empty chains for namespace" case .unsupportedMethods: diff --git a/Sources/WalletConnectUtils/RPCHistory.swift b/Sources/WalletConnectUtils/RPCHistory.swift index dafb70d36..ae0b0df71 100644 --- a/Sources/WalletConnectUtils/RPCHistory.swift +++ b/Sources/WalletConnectUtils/RPCHistory.swift @@ -64,4 +64,8 @@ public final class RPCHistory { } } } + + public func getPending() -> [Record] { + storage.getAll().filter {$0.response == nil} + } } diff --git a/Sources/WalletConnectUtils/WalletConnectURI.swift b/Sources/WalletConnectUtils/WalletConnectURI.swift index d149f7f70..18a0a6a66 100644 --- a/Sources/WalletConnectUtils/WalletConnectURI.swift +++ b/Sources/WalletConnectUtils/WalletConnectURI.swift @@ -2,42 +2,60 @@ import Foundation public struct WalletConnectURI: Equatable { + public enum TargetAPI: String, CaseIterable { + case sign + case chat + case auth + } + public let topic: String public let version: String public let symKey: String public let relay: RelayProtocolOptions - public init(topic: String, symKey: String, relay: RelayProtocolOptions) { + public var api: TargetAPI { + apiType ?? .sign + } + + public var absoluteString: String { + if let api = apiType { + return "wc:\(api.rawValue)-\(topic)@\(version)?symKey=\(symKey)&\(relayQuery)" + } + return "wc:\(topic)@\(version)?symKey=\(symKey)&\(relayQuery)" + } + + private let apiType: TargetAPI? + + public init(topic: String, symKey: String, relay: RelayProtocolOptions, api: TargetAPI? = nil) { self.version = "2" self.topic = topic self.symKey = symKey self.relay = relay + self.apiType = api } public init?(string: String) { - guard string.hasPrefix("wc:") else { - return nil - } - let urlString = !string.hasPrefix("wc://") ? string.replacingOccurrences(of: "wc:", with: "wc://") : string - guard let components = URLComponents(string: urlString) else { + guard let components = Self.parseURIComponents(from: string) else { return nil } let query: [String: String]? = components.queryItems?.reduce(into: [:]) { $0[$1.name] = $1.value } - guard let topic = components.user, - let version = components.host, - let symKey = query?["symKey"], - let relayProtocol = query?["relay-protocol"] - else { return nil } + guard + let userString = components.user, + let version = components.host, + let symKey = query?["symKey"], + let relayProtocol = query?["relay-protocol"] + else { + return nil + } + let uriUser = Self.parseURIUser(from: userString) let relayData = query?["relay-data"] + self.version = version - self.topic = topic + self.topic = uriUser.topic self.symKey = symKey self.relay = RelayProtocolOptions(protocol: relayProtocol, data: relayData) - } - - public var absoluteString: String { - return "wc:\(topic)@\(version)?symKey=\(symKey)&\(relayQuery)" + self.apiType = uriUser.api } private var relayQuery: String { @@ -47,4 +65,21 @@ public struct WalletConnectURI: Equatable { } return query } + + private static func parseURIComponents(from string: String) -> URLComponents? { + guard string.hasPrefix("wc:") else { + return nil + } + let urlString = !string.hasPrefix("wc://") ? string.replacingOccurrences(of: "wc:", with: "wc://") : string + return URLComponents(string: urlString) + } + + private static func parseURIUser(from string: String) -> (topic: String, api: TargetAPI?) { + let splits = string.split(separator: "-") + if splits.count == 2, let apiFromPrefix = TargetAPI(rawValue: String(splits[0])) { + return (String(splits[1]), apiFromPrefix) + } else { + return (string, nil) + } + } } diff --git a/Tests/AuthTests/AuthRequstSubscriberTests.swift b/Tests/AuthTests/AuthRequstSubscriberTests.swift index 990a9cb4f..fc7c6ebbb 100644 --- a/Tests/AuthTests/AuthRequstSubscriberTests.swift +++ b/Tests/AuthTests/AuthRequstSubscriberTests.swift @@ -8,16 +8,17 @@ import JSONRPC class AuthRequstSubscriberTests: XCTestCase { var networkingInteractor: NetworkingInteractorMock! - var sut: AuthRequestSubscriber! + var sut: WalletRequestSubscriber! var messageFormatter: SIWEMessageFormatterMock! let defaultTimeout: TimeInterval = 0.01 override func setUp() { networkingInteractor = NetworkingInteractorMock() messageFormatter = SIWEMessageFormatterMock() - sut = AuthRequestSubscriber(networkingInteractor: networkingInteractor, - logger: ConsoleLoggerMock(), - messageFormatter: messageFormatter, address: "") + sut = WalletRequestSubscriber(networkingInteractor: networkingInteractor, + logger: ConsoleLoggerMock(), + kms: KeyManagementServiceMock(), + messageFormatter: messageFormatter, address: "") } func testSubscribeRequest() { diff --git a/Tests/AuthTests/CacaoSignerTests.swift b/Tests/AuthTests/CacaoSignerTests.swift new file mode 100644 index 000000000..a1615a361 --- /dev/null +++ b/Tests/AuthTests/CacaoSignerTests.swift @@ -0,0 +1,40 @@ +import Foundation +import XCTest +@testable import Auth +import TestingUtils + +class CacaoSignerTest: XCTestCase { + + let privateKey = Data(hex: "305c6cde3846927892cd32762f6120539f3ec74c9e3a16b9b798b1e85351ae2a") + + let message: String = + """ + service.invalid wants you to sign in with your Ethereum account: + 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + + I accept the ServiceOrg Terms of Service: https://service.invalid/tos + + URI: https://service.invalid/login + Version: 1 + Chain ID: 1 + Nonce: 32891756 + Issued At: 2021-09-30T16:25:24Z + Resources: + - ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/ + - https://example.com/my-web2-claim.json + """ + + let signature = "438effc459956b57fcd9f3dac6c675f9cee88abf21acab7305e8e32aa0303a883b06dcbd956279a7a2ca21ffa882ff55cc22e8ab8ec0f3fe90ab45f306938cfa1b" + + func testCacaoSign() throws { + let signer = MessageSigner(signer: Signer()) + + XCTAssertEqual(try signer.sign(message: message, privateKey: privateKey), signature) + } + + func testCacaoVerify() throws { + let signer = MessageSigner(signer: Signer()) + + try signer.verify(signature: signature, message: message, address: "0x15bca56b6e2728aec2532df9d436bd1600e86688") + } +} diff --git a/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift b/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift index 838b47ea7..a499ea4af 100644 --- a/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift +++ b/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift @@ -32,6 +32,10 @@ struct NetworkingInteractorMock: NetworkInteracting { } + func respondError(topic: String, requestId: RPCID, tag: Int, reason: Reason, envelopeType: Envelope.EnvelopeType) async throws { + + } + func requestNetworkAck(_ request: RPCRequest, topic: String, tag: Int) async throws { } diff --git a/Tests/AuthTests/Mocks/SIWEMessageFormatterMock.swift b/Tests/AuthTests/Mocks/SIWEMessageFormatterMock.swift index bce7c4824..086ebba3c 100644 --- a/Tests/AuthTests/Mocks/SIWEMessageFormatterMock.swift +++ b/Tests/AuthTests/Mocks/SIWEMessageFormatterMock.swift @@ -3,7 +3,12 @@ import Foundation class SIWEMessageFormatterMock: SIWEMessageFormatting { var formattedMessage: String! + func formatMessage(from authPayload: AuthPayload, address: String) -> String { return formattedMessage } + + func formatMessage(from payload: CacaoPayload) throws -> String { + return formattedMessage + } } diff --git a/Tests/AuthTests/SignerTests.swift b/Tests/AuthTests/SignerTests.swift new file mode 100644 index 000000000..5f546bd08 --- /dev/null +++ b/Tests/AuthTests/SignerTests.swift @@ -0,0 +1,65 @@ +import Foundation +import XCTest +@testable import Auth +import secp256k1 +import Web3 +import WalletConnectUtils + +class SignerTest: XCTestCase { + + private let signer = Signer() + + private let message = "Message".data(using: .utf8)! + private let privateKey = Data(hex: "305c6cde3846927892cd32762f6120539f3ec74c9e3a16b9b798b1e85351ae2a") + private let signature = Data(hex: "66121e60cccc01fbf7fcba694a1e08ac5db35fb4ec6c045bedba7860445b95c021cad2c595f0bf68ff896964c7c02bb2f3a3e9540e8e4595c98b737ce264cc541b") + private var address = "0x15bca56b6e2728aec2532df9d436bd1600e86688" + + func testValidSignature() throws { + let result = try signer.sign(message: message, with: privateKey) + + XCTAssertEqual(signature.toHexString(), result.toHexString()) + XCTAssertTrue(try signer.isValid(signature: result, message: message, address: address)) + } + + func testEtherscanSignature() throws { + let addressFromEtherscan = "0x6721591d424c18b7173d55895efa1839aa57d9c2" + let message = "[Etherscan.io 12/08/2022 09:26:23] I, hereby verify that I am the owner/creator of the address [0x7e77dcb127f99ece88230a64db8d595f31f1b068]" + let signedMessageFromEtherscan = message.data(using: .utf8)! + let signatureHashFromEtherscan = Data(hex: "60eb9cfe362210f1b4855f4865eafc378bd442c406de22354cc9f643fb84cb265b7f6d9d10b13199e450558c328814a9038884d9993d9feb79b727366736853d1b") + XCTAssertTrue(try signer.isValid( + signature: signatureHashFromEtherscan, + message: signedMessageFromEtherscan, + address: addressFromEtherscan + )) + } + + func testInvalidMessage() throws { + let message = "Message One".data(using: .utf8)! + + XCTAssertFalse(try signer.isValid(signature: signature, message: message, address: address)) + } + + func testInvalidPubKey() throws { + let address = "0xBAc675C310721717Cd4A37F6cbeA1F081b1C2a07" + + XCTAssertFalse(try signer.isValid(signature: signature, message: message, address: address)) + } + + func testInvalidSignature() throws { + let signature = Data(hex: "86deb09d045608f2753ef12f46e8da5fc2559e3a9162e580df3e62c875df7c3f64433462a59bc4ff38ce52412bff10527f4b99cc078f63ef2bb4a6f7427080aa01") + + XCTAssertFalse(try signer.isValid(signature: signature, message: message, address: address)) + } + + func testSignerAddressFromIss() throws { + let iss = "did:pkh:eip155:1:0xBAc675C310721717Cd4A37F6cbeA1F081b1C2a07" + + XCTAssertEqual(try DIDPKH(iss: iss).account, Account("eip155:1:0xBAc675C310721717Cd4A37F6cbeA1F081b1C2a07")!) + } + + func testSignerAddressFromAccount() throws { + let account = Account("eip155:1:0xBAc675C310721717Cd4A37F6cbeA1F081b1C2a07")! + + XCTAssertEqual(DIDPKH(account: account).iss, "did:pkh:eip155:1:0xBAc675C310721717Cd4A37F6cbeA1F081b1C2a07") + } +} diff --git a/Tests/RelayerTests/Mocks/BackgroundTaskRegistrarMock.swift b/Tests/RelayerTests/Mocks/BackgroundTaskRegistrarMock.swift index d13eefefb..d8f895242 100644 --- a/Tests/RelayerTests/Mocks/BackgroundTaskRegistrarMock.swift +++ b/Tests/RelayerTests/Mocks/BackgroundTaskRegistrarMock.swift @@ -3,7 +3,12 @@ import Foundation class BackgroundTaskRegistrarMock: BackgroundTaskRegistering { var completion: (() -> Void)? + func register(name: String, completion: @escaping () -> Void) { self.completion = completion } + + func invalidate() { + + } } diff --git a/Tests/RelayerTests/RelayClientTests.swift b/Tests/RelayerTests/RelayClientTests.swift index d45bb61fe..f9c9384c2 100644 --- a/Tests/RelayerTests/RelayClientTests.swift +++ b/Tests/RelayerTests/RelayClientTests.swift @@ -10,6 +10,8 @@ final class RelayClientTests: XCTestCase { var sut: RelayClient! var dispatcher: DispatcherMock! + var publishers = Set() + override func setUp() { dispatcher = DispatcherMock() let logger = ConsoleLogger() @@ -29,11 +31,12 @@ final class RelayClientTests: XCTestCase { let subscription = Subscription(id: subscriptionId, topic: topic, message: message) let request = subscription.asRPCRequest() - sut.onMessage = { subscriptionTopic, subscriptionMessage in + sut.messagePublisher.sink { (subscriptionTopic, subscriptionMessage) in XCTAssertEqual(subscriptionMessage, message) XCTAssertEqual(subscriptionTopic, topic) expectation.fulfill() - } + }.store(in: &publishers) + dispatcher.onMessage?(try! request.asJSONEncodedString()) waitForExpectations(timeout: 0.001, handler: nil) } @@ -79,9 +82,11 @@ final class RelayClientTests: XCTestCase { func testSubscriptionRequestDeliveredOnce() { let expectation = expectation(description: "Duplicate Subscription requests must notify only the first time") let request = Subscription.init(id: "sub_id", topic: "topic", message: "message").asRPCRequest() - sut.onMessage = { _, _ in + + sut.messagePublisher.sink { (_, _) in expectation.fulfill() - } + }.store(in: &publishers) + dispatcher.onMessage?(try! request.asJSONEncodedString()) dispatcher.onMessage?(try! request.asJSONEncodedString()) waitForExpectations(timeout: 0.1, handler: nil) diff --git a/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift b/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift index 98f57f0ef..eac4dacb5 100644 --- a/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift +++ b/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift @@ -4,25 +4,31 @@ import Foundation @testable import WalletConnectSign class MockedRelayClient: NetworkRelaying { - func subscribe(topic: String) async throws {} + + var messagePublisherSubject = PassthroughSubject<(topic: String, message: String), Never>() + var messagePublisher: AnyPublisher<(topic: String, message: String), Never> { + messagePublisherSubject.eraseToAnyPublisher() + } var socketConnectionStatusPublisherSubject = PassthroughSubject() var socketConnectionStatusPublisher: AnyPublisher { socketConnectionStatusPublisherSubject.eraseToAnyPublisher() } + var error: Error? + var prompt = false + func publish(topic: String, payload: String, tag: Int, prompt: Bool) async throws { self.prompt = prompt } - var onMessage: ((String, String) -> Void)? - var error: Error? - var prompt = false func publish(topic: String, payload: String, tag: Int, prompt: Bool, onNetworkAcknowledge: @escaping ((Error?) -> Void)) { self.prompt = prompt onNetworkAcknowledge(error) } + func subscribe(topic: String) async throws {} + func subscribe(topic: String, completion: @escaping (Error?) -> Void) { } diff --git a/Tests/WalletConnectSignTests/NonControllerSessionStateMachineTests.swift b/Tests/WalletConnectSignTests/NonControllerSessionStateMachineTests.swift index 5e254eb4c..47937bd42 100644 --- a/Tests/WalletConnectSignTests/NonControllerSessionStateMachineTests.swift +++ b/Tests/WalletConnectSignTests/NonControllerSessionStateMachineTests.swift @@ -52,7 +52,7 @@ class NonControllerSessionStateMachineTests: XCTestCase { networkingInteractor.wcRequestPublisherSubject.send(WCRequestSubscriptionPayload.stubUpdateNamespaces(topic: "")) usleep(100) XCTAssertFalse(networkingInteractor.didRespondSuccess) - XCTAssertEqual(networkingInteractor.lastErrorCode, 1301) + XCTAssertEqual(networkingInteractor.lastErrorCode, 7001) } func testUpdateMethodPeerErrorUnauthorized() { @@ -61,7 +61,7 @@ class NonControllerSessionStateMachineTests: XCTestCase { networkingInteractor.wcRequestPublisherSubject.send(WCRequestSubscriptionPayload.stubUpdateNamespaces(topic: session.topic)) usleep(100) XCTAssertFalse(networkingInteractor.didRespondSuccess) - XCTAssertEqual(networkingInteractor.lastErrorCode, 3004) + XCTAssertEqual(networkingInteractor.lastErrorCode, 3003) } // MARK: - Update Expiry diff --git a/Tests/WalletConnectSignTests/WCPairingTests.swift b/Tests/WalletConnectSignTests/WCPairingTests.swift index 6826add6d..ca6872e24 100644 --- a/Tests/WalletConnectSignTests/WCPairingTests.swift +++ b/Tests/WalletConnectSignTests/WCPairingTests.swift @@ -1,5 +1,5 @@ import XCTest -import WalletConnectPairing +@testable import WalletConnectPairing @testable import WalletConnectSign final class WCPairingTests: XCTestCase { @@ -35,18 +35,67 @@ final class WCPairingTests: XCTestCase { XCTAssertEqual(pairing.expiryDate, inactiveExpiry) } - func testUpdateExpiry() { + func testUpdateExpiryForTopic() { var pairing = WCPairing(topic: "") let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) try? pairing.updateExpiry() XCTAssertEqual(pairing.expiryDate, activeExpiry) } + + func testUpdateExpiryForUri() { + var pairing = WCPairing(uri: WalletConnectURI.stub()) + let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) + try? pairing.updateExpiry() + XCTAssertEqual(pairing.expiryDate, activeExpiry) + } - func testActivate() { + func testActivateTopic() { var pairing = WCPairing(topic: "") let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) + XCTAssertFalse(pairing.active) + pairing.activate() + XCTAssertTrue(pairing.active) + XCTAssertEqual(pairing.expiryDate, activeExpiry) + } + + func testActivateURI() { + var pairing = WCPairing(uri: WalletConnectURI.stub()) + let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) + XCTAssertFalse(pairing.active) pairing.activate() XCTAssertTrue(pairing.active) XCTAssertEqual(pairing.expiryDate, activeExpiry) } + + func testUpdateExpiryWhenValueIsGreaterThanMax() { + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: referenceDate) + XCTAssertThrowsError(try pairing.updateExpiry(40 * .day)) { error in + XCTAssertEqual(error as! WCPairing.Errors, WCPairing.Errors.invalidUpdateExpiryValue) + } + } + + func testUpdateExpiryWhenNewExpiryDateIsLessThanExpiryDate() { + let expiryDate = referenceDate.advanced(by: 40 * .day) + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: expiryDate) + XCTAssertThrowsError(try pairing.updateExpiry(10 * .minute)) { error in + XCTAssertEqual(error as! WCPairing.Errors, WCPairing.Errors.invalidUpdateExpiryValue) + } + } + + func testActivateWhenCanUpdateExpiry() { + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: referenceDate) + XCTAssertFalse(pairing.active) + pairing.activate() + XCTAssertTrue(pairing.active) + XCTAssertEqual(referenceDate.advanced(by: 30 * .day), pairing.expiryDate) + } + + func testActivateWhenUpdateExpiryIsInvalid() { + let expiryDate = referenceDate.advanced(by: 40 * .day) + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: expiryDate) + XCTAssertFalse(pairing.active) + pairing.activate() + XCTAssertTrue(pairing.active) + XCTAssertEqual(expiryDate, pairing.expiryDate) + } } diff --git a/Tests/WalletConnectSignTests/WCRelayTests.swift b/Tests/WalletConnectSignTests/WCRelayTests.swift index 500967141..a42fbd328 100644 --- a/Tests/WalletConnectSignTests/WCRelayTests.swift +++ b/Tests/WalletConnectSignTests/WCRelayTests.swift @@ -33,7 +33,7 @@ class NetworkingInteractorTests: XCTestCase { requestExpectation.fulfill() }.store(in: &publishers) serializer.deserialized = request - relayClient.onMessage?(topic, testPayload) + relayClient.messagePublisherSubject.send((topic, testPayload)) waitForExpectations(timeout: 1.001, handler: nil) } diff --git a/Tests/WalletConnectSignTests/WalletConnectURITests.swift b/Tests/WalletConnectSignTests/WalletConnectURITests.swift deleted file mode 100644 index e1e218a4b..000000000 --- a/Tests/WalletConnectSignTests/WalletConnectURITests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import XCTest -@testable import WalletConnectSign - -private let stubTopic = "8097df5f14871126866252c1b7479a14aefb980188fc35ec97d130d24bd887c8" -private let stubSymKey = "587d5484ce2a2a6ee3ba1962fdd7e8588e06200c46823bd18fbd67def96ad303" -private let stubProtocol = "irn" - -private let stubURI = "wc:7f6e504bfad60b485450578e05678ed3e8e8c4751d3c6160be17160d63ec90f9@2?symKey=587d5484ce2a2a6ee3ba1962fdd7e8588e06200c46823bd18fbd67def96ad303&relay-protocol=irn" - -final class WalletConnectURITests: XCTestCase { - - func testInitURIToString() { - let inputURI = WalletConnectURI( - topic: "8097df5f14871126866252c1b7479a14aefb980188fc35ec97d130d24bd887c8", - symKey: "19c5ecc857963976fabb98ed6a3e0a6ab6b0d65c018b6e25fbdcd3a164def868", - relay: RelayProtocolOptions(protocol: "irn", data: nil)) - let uriString = inputURI.absoluteString - let outputURI = WalletConnectURI(string: uriString) - XCTAssertEqual(inputURI, outputURI) - } - - func testInitStringToURI() { - let inputURIString = stubURI - let uri = WalletConnectURI(string: inputURIString) - let outputURIString = uri?.absoluteString - XCTAssertEqual(inputURIString, outputURIString) - } - - func testInitStringToURIAlternate() { - let expectedString = stubURI - let inputURIString = expectedString.replacingOccurrences(of: "wc:", with: "wc://") - let uri = WalletConnectURI(string: inputURIString) - let outputURIString = uri?.absoluteString - XCTAssertEqual(expectedString, outputURIString) - } - - func testInitFailsBadScheme() { - let inputURIString = stubURI.replacingOccurrences(of: "wc:", with: "") - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) - } - - func testInitFailsMalformedURL() { - let inputURIString = "wc://<" - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) - } - - func testInitFailsNoSymKeyParam() { - let inputURIString = stubURI.replacingOccurrences(of: "symKey=\(stubSymKey)", with: "") - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) - } - - func testInitFailsNoRelayParam() { - let inputURIString = stubURI.replacingOccurrences(of: "&relay-protocol=\(stubProtocol)", with: "") - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) - } -} diff --git a/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift b/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift new file mode 100644 index 000000000..fc2f8c170 --- /dev/null +++ b/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import WalletConnectUtils + +private func stubURI(api: WalletConnectURI.TargetAPI? = nil) -> (uri: WalletConnectURI, string: String) { + let topic = Data.randomBytes(count: 32).toHexString() + let symKey = Data.randomBytes(count: 32).toHexString() + let protocolName = "irn" + let uriBase = api == nil ? "wc:" : "wc:\(api!.rawValue)-" + let uriString = "\(uriBase)\(topic)@2?symKey=\(symKey)&relay-protocol=\(protocolName)" + let uri = WalletConnectURI( + topic: topic, + symKey: symKey, + relay: RelayProtocolOptions(protocol: protocolName, data: nil), + api: api) + return (uri, uriString) +} + +final class WalletConnectURITests: XCTestCase { + + // MARK: - Init URI with string + + func testInitURIToString() { + let input = stubURI() + let uriString = input.uri.absoluteString + let outputURI = WalletConnectURI(string: uriString) + XCTAssertEqual(input.uri, outputURI) + XCTAssertEqual(input.string, outputURI?.absoluteString) + } + + func testInitStringToURI() { + let inputURIString = stubURI().string + let uri = WalletConnectURI(string: inputURIString) + let outputURIString = uri?.absoluteString + XCTAssertEqual(inputURIString, outputURIString) + } + + func testInitStringToURIAlternate() { + let expectedString = stubURI().string + let inputURIString = expectedString.replacingOccurrences(of: "wc:", with: "wc://") + let uri = WalletConnectURI(string: inputURIString) + let outputURIString = uri?.absoluteString + XCTAssertEqual(expectedString, outputURIString) + } + + // MARK: - Init URI with prefix API identifier + + func testInitFromPrefixedURIString() { + WalletConnectURI.TargetAPI.allCases.forEach { api in + let uriString = stubURI(api: api).string + let uri = WalletConnectURI(string: uriString) + XCTAssertEqual(uri?.api, api) + XCTAssertEqual(uri?.absoluteString, uriString) + } + } + + func testAbsentPrefixFallbackToSign() { + let input = stubURI() + let uriFromParams = input.uri + let uriFromString = WalletConnectURI(string: input.string) + XCTAssertEqual(uriFromParams.api, .sign) + XCTAssertEqual(uriFromString?.api, .sign) + } + + // MARK: - Init URI failure cases + + func testInitFailsBadScheme() { + let inputURIString = stubURI().string.replacingOccurrences(of: "wc:", with: "") + let uri = WalletConnectURI(string: inputURIString) + XCTAssertNil(uri) + } + + func testInitFailsMalformedURL() { + let inputURIString = "wc://<" + let uri = WalletConnectURI(string: inputURIString) + XCTAssertNil(uri) + } + + func testInitFailsNoSymKeyParam() { + let input = stubURI() + let inputURIString = input.string.replacingOccurrences(of: "symKey=\(input.uri.symKey)", with: "") + let uri = WalletConnectURI(string: inputURIString) + XCTAssertNil(uri) + } + + func testInitFailsNoRelayParam() { + let input = stubURI() + let inputURIString = input.string.replacingOccurrences(of: "&relay-protocol=\(input.uri.relay.protocol)", with: "") + let uri = WalletConnectURI(string: inputURIString) + XCTAssertNil(uri) + } +}