diff --git a/Example/DApp/ClientDelegate.swift b/Example/DApp/ClientDelegate.swift index b958fd659..8df4434d6 100644 --- a/Example/DApp/ClientDelegate.swift +++ b/Example/DApp/ClientDelegate.swift @@ -31,7 +31,7 @@ class ClientDelegate: WalletConnectClientDelegate { onSessionResponse?(sessionResponse) } - func didUpdate(sessionTopic: String, accounts: Set) { + func didUpdate(sessionTopic: String, accounts: Set) { } func didUpgrade(sessionTopic: String, permissions: Session.Permissions) { diff --git a/Example/ExampleApp/Responder/ResponderViewController.swift b/Example/ExampleApp/Responder/ResponderViewController.swift index 056bd298c..fb1b3fe03 100644 --- a/Example/ExampleApp/Responder/ResponderViewController.swift +++ b/Example/ExampleApp/Responder/ResponderViewController.swift @@ -150,8 +150,8 @@ extension ResponderViewController: SessionViewControllerDelegate { print("[RESPONDER] Approving session...") let proposal = currentProposal! currentProposal = nil - let accounts = proposal.permissions.blockchains.map {$0+":\(account)"} - client.approve(proposal: proposal, accounts: Set(accounts)) + let accounts = Set(proposal.permissions.blockchains.compactMap { Account($0+":\(account)") }) + client.approve(proposal: proposal, accounts: accounts) } func didRejectSession() { @@ -196,7 +196,7 @@ extension ResponderViewController: WalletConnectClientDelegate { } - func didUpdate(sessionTopic: String, accounts: Set) { + func didUpdate(sessionTopic: String, accounts: Set) { } diff --git a/Sources/WalletConnect/Account.swift b/Sources/WalletConnect/Account.swift new file mode 100644 index 000000000..60dfcd1aa --- /dev/null +++ b/Sources/WalletConnect/Account.swift @@ -0,0 +1,94 @@ +/** + A value that identifies an account in any given blockchain. + + This structure parses account IDs according to [CAIP-10]. + Account IDs are prefixed with a [CAIP-2] blockchain ID, delimited by a `':'` character, followed by the account address. + + Specifying a blockchain account by using a chain-agnostic identifier is useful to allow interoperability between multiple + chains when using both wallets and decentralized applications. + + [CAIP-2]:https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-2.md + [CAIP-10]:https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md + */ +public struct Account: Equatable, Hashable { + + /// A blockchain namespace. Usually describes an ecosystem or standard. + public let namespace: String + + /// A reference string that identifies a blockchain within a given namespace. + public let reference: String + + /// The account's address specific to the blockchain. + public let address: String + + /// The CAIP-2 blockchain identifier of the account. + public var blockchainIdentifier: String { + "\(namespace):\(reference)" + } + + /// The CAIP-10 account identifier absolute string. + public var absoluteString: String { + "\(namespace):\(reference):\(address)" + } + + /// Returns whether the account conforms to CAIP-10. + public var isCAIP10Conformant: Bool { + String.conformsToCAIP10(absoluteString) + } + + /** + Creates an account instance from the provided string. + + This initializer returns nil if the string doesn't represent a valid account id in conformance with + [CAIP-10](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md). + */ + public init?(_ string: String) { + guard String.conformsToCAIP10(string) else { return nil } + let splits = string.split(separator: ":") + self.init(namespace: String(splits[0]), reference: String(splits[1]), address: String(splits[2])) + } + + /** + Creates an account instance from a chain ID and an address. + + This initializer returns nil if the `chainIdentifier` parameter doesn't represent a valid chain id in conformance with + [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-2.md) or if the `address` format is invalid. + */ + public init?(chainIdentifier: String, address: String) { + self.init("\(chainIdentifier):\(address)") + } + + /** + Creates an account instance directly from the base components. + + This initializer bypass any checks to CAIP conformance, make sure to pass valid values as parameters. + */ + public init(namespace: String, reference: String, address: String) { + self.namespace = namespace + self.reference = reference + self.address = address + } +} + +extension Account: LosslessStringConvertible { + public var description: String { + return absoluteString + } +} + +extension Account: Codable { + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let absoluteString = try container.decode(String.self) + guard let account = Account(absoluteString) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Malformed CAIP-10 account identifier.") + } + self = account + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(absoluteString) + } +} diff --git a/Sources/WalletConnect/WalletConnectClient.swift b/Sources/WalletConnect/WalletConnectClient.swift index ce994d13b..be8d586ba 100644 --- a/Sources/WalletConnect/WalletConnectClient.swift +++ b/Sources/WalletConnect/WalletConnectClient.swift @@ -137,8 +137,8 @@ public final class WalletConnectClient { /// - Parameters: /// - proposal: Session Proposal 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. - public func approve(proposal: Session.Proposal, accounts: Set) { - sessionEngine.approve(proposal: proposal.proposal, accounts: accounts) + public func approve(proposal: Session.Proposal, accounts: Set) { + sessionEngine.approve(proposal: proposal.proposal, accounts: Set(accounts.map { $0.absoluteString })) } /// For the responder to reject a session proposal. @@ -153,12 +153,8 @@ public final class WalletConnectClient { /// - Parameters: /// - topic: Topic of the session that is intended to be updated. /// - accounts: Set of accounts that will be allowed to be used by the session after the update. - public func update(topic: String, accounts: Set) { - do { - try sessionEngine.update(topic: topic, accounts: accounts) - } catch { - print("Error on session update call: \(error)") - } + public func update(topic: String, accounts: Set) throws { + try sessionEngine.update(topic: topic, accounts: Set(accounts.map { $0.absoluteString })) } /// For the responder to upgrade session permissions @@ -305,7 +301,7 @@ public final class WalletConnectClient { delegate?.didUpgrade(sessionTopic: topic, permissions: upgradedPermissions) } sessionEngine.onSessionUpdate = { [unowned self] topic, accounts in - delegate?.didUpdate(sessionTopic: topic, accounts: accounts) + delegate?.didUpdate(sessionTopic: topic, accounts: Set(accounts.compactMap { Account($0) })) } sessionEngine.onNotificationReceived = { [unowned self] topic, notification in delegate?.didReceive(notification: notification, sessionTopic: topic) diff --git a/Sources/WalletConnect/WalletConnectClientDelegate.swift b/Sources/WalletConnect/WalletConnectClientDelegate.swift index a9df37c0a..b235d0320 100644 --- a/Sources/WalletConnect/WalletConnectClientDelegate.swift +++ b/Sources/WalletConnect/WalletConnectClientDelegate.swift @@ -36,7 +36,7 @@ public protocol WalletConnectClientDelegate: AnyObject { /// Tells the delegate that extra accounts has been included in session sequence /// /// Function is executed on controller and non-controller client when both communicating peers have successfully included new accounts requested by the controller client. - func didUpdate(sessionTopic: String, accounts: Set) + func didUpdate(sessionTopic: String, accounts: Set) /// Tells the delegate that the client has settled a session. /// diff --git a/Tests/IntegrationTests/ClientDelegate.swift b/Tests/IntegrationTests/ClientDelegate.swift index f5eacaeb0..523beb6f5 100644 --- a/Tests/IntegrationTests/ClientDelegate.swift +++ b/Tests/IntegrationTests/ClientDelegate.swift @@ -12,7 +12,7 @@ class ClientDelegate: WalletConnectClientDelegate { var onSessionRejected: ((String, Reason)->())? var onSessionDelete: (()->())? var onSessionUpgrade: ((String, Session.Permissions)->())? - var onSessionUpdate: ((String, Set)->())? + var onSessionUpdate: ((String, Set)->())? var onNotificationReceived: ((Session.Notification, String)->())? var onPairingUpdate: ((String, AppMetadata)->())? @@ -42,7 +42,7 @@ class ClientDelegate: WalletConnectClientDelegate { func didUpgrade(sessionTopic: String, permissions: Session.Permissions) { onSessionUpgrade?(sessionTopic, permissions) } - func didUpdate(sessionTopic: String, accounts: Set) { + func didUpdate(sessionTopic: String, accounts: Set) { onSessionUpdate?(sessionTopic, accounts) } func didReceive(notification: Session.Notification, sessionTopic: String) { diff --git a/Tests/IntegrationTests/ClientTest.swift b/Tests/IntegrationTests/ClientTest.swift index df2716839..e17e4ea0e 100644 --- a/Tests/IntegrationTests/ClientTest.swift +++ b/Tests/IntegrationTests/ClientTest.swift @@ -56,7 +56,7 @@ final class ClientTests: XCTestCase { func testNewSession() { let proposerSettlesSessionExpectation = expectation(description: "Proposer settles session") let responderSettlesSessionExpectation = expectation(description: "Responder settles session") - let account = "0x022c0c42a80bd19EA4cF0F94c4F9F96645759716" + let account = Account("eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb")! let permissions = Session.Permissions.stub() let uri = try! proposer.client.connect(sessionPermissions: permissions)! @@ -244,7 +244,7 @@ final class ClientTests: XCTestCase { func testSuccessfulSessionUpgrade() { let proposerSessionUpgradeExpectation = expectation(description: "Proposer upgrades session on responder request") let responderSessionUpgradeExpectation = expectation(description: "Responder upgrades session on proposer response") - let account = "0x022c0c42a80bd19EA4cF0F94c4F9F96645759716" + let account = Account("eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb")! let permissions = Session.Permissions.stub() let upgradePermissions = Session.Permissions(blockchains: ["eip155:42"], methods: ["eth_sendTransaction"]) let uri = try! proposer.client.connect(sessionPermissions: permissions)! @@ -271,8 +271,8 @@ final class ClientTests: XCTestCase { func testSuccessfulSessionUpdate() { let proposerSessionUpdateExpectation = expectation(description: "Proposer updates session on responder request") let responderSessionUpdateExpectation = expectation(description: "Responder updates session on proposer response") - let account = "eip155:42:0x022c0c42a80bd19EA4cF0F94c4F9F96645759716" - let updateAccounts: Set = ["eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb"] + let account = Account("eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb")! + let updateAccounts: Set = [Account("eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdf")!] let permissions = Session.Permissions.stub() let uri = try! proposer.client.connect(sessionPermissions: permissions)! try! responder.client.pair(uri: uri) @@ -280,7 +280,7 @@ final class ClientTests: XCTestCase { self.responder.client.approve(proposal: proposal, accounts: [account]) } responder.onSessionSettled = { [unowned self] sessionSettled in - responder.client.update(topic: sessionSettled.topic, accounts: updateAccounts) + try? responder.client.update(topic: sessionSettled.topic, accounts: updateAccounts) } responder.onSessionUpdate = { _, accounts in XCTAssertEqual(accounts, updateAccounts) diff --git a/Tests/WalletConnectTests/AccountTests.swift b/Tests/WalletConnectTests/AccountTests.swift new file mode 100644 index 000000000..006cf9df4 --- /dev/null +++ b/Tests/WalletConnectTests/AccountTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import WalletConnect + +final class AccountTests: XCTestCase { + + func testInitFromString() { + // Valid accounts + XCTAssertNotNil(Account("std:0:0")) + XCTAssertNotNil(Account("chainstd:8c3444cf8970a9e41a706fab93e7a6c4:6d9b0b4b9994e8a6afbd3dc3ed983cd51c755afb27cd1dc7825ef59c134a39f7")) + + // Invalid accounts + XCTAssertNil(Account("std:0:$")) + XCTAssertNil(Account("std:$:0")) + XCTAssertNil(Account("st:0:0")) + } + + func testInitFromChainAndAddress() { + // Valid accounts + XCTAssertNotNil(Account(chainIdentifier: "std:0", address: "0")) + XCTAssertNotNil(Account(chainIdentifier: "chainstd:8c3444cf8970a9e41a706fab93e7a6c4", address: "6d9b0b4b9994e8a6afbd3dc3ed983cd51c755afb27cd1dc7825ef59c134a39f7")) + + // Invalid accounts + XCTAssertNil(Account(chainIdentifier: "std:0", address: "")) + XCTAssertNil(Account(chainIdentifier: "std", address: "0")) + } + + func testInitCAIP10Conformance() { + XCTAssertTrue(Account(namespace: "std", reference: "0", address: "0").isCAIP10Conformant) + + XCTAssertFalse(Account(namespace: "st", reference: "0", address: "0").isCAIP10Conformant) + XCTAssertFalse(Account(namespace: "std", reference: "", address: "0").isCAIP10Conformant) + XCTAssertFalse(Account(namespace: "std", reference: "0", address: "").isCAIP10Conformant) + } + + func testBlockchainIdentifier() { + let account = Account("eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb")! + XCTAssertEqual(account.blockchainIdentifier, "eip155:1") + } + + func testAbsoluteString() { + let accountString = "eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb" + let account = Account(accountString)! + XCTAssertEqual(account.absoluteString, accountString) + } + + func testCodable() throws { + let account = Account("eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb")! + let encoded = try JSONEncoder().encode(account) + let decoded = try JSONDecoder().decode(Account.self, from: encoded) + XCTAssertEqual(account, decoded) + } +}