从2017年下半年开始,区块链技术迅速火遍技术圈,投资圈,炒币圈...背景就不多说了,自行查阅。工作需要,近几个月一直在了解学习区块链相关的技术,也尝试着做出了一个基于以太坊的钱包APP,支持Token(ERC20)及DAPP。其实大部分技术基础都来源于github上的一些优秀的开源钱包代码。这篇文章就以 Trust Wallet 这个开源项目为例,描述一下典型的钱包应用都具备哪些功能和支撑的技术点,共同学习。
PS.区块链技术涉及到的知识点很庞杂,一些如区块链、以太坊、智能合约、钱包等基础概念就不细说了,感兴趣的可自行查阅
Trust Wallet
在众多开源的虚拟币钱包项目里,Trust做的算是非常完善和稳定的,代码风格、架构设计,技术栈都很新颖,并且已经在国外的AppStore上架,对应的Android版本也已开源。下面是Trust iOS版本的一些截图,四个Tab页分别为钱包、交易、Token、DAPP浏览器
- DAPP浏览器:基于Web浏览器,提供其支持的若干个交易网站,如加密猫游戏等,支持web在移动端进行本地钱包交易的功能
- 交易:展示当前钱包地址在以太坊中产生的交易记录,包括Token的交易记录,同时可收发以太币
- 通证:展示当前钱包地址在以太坊中,以及发生过交易的Token(ERC20)的余额状态;以太坊Token的转账功能
- 设置:切换钱包地址和目标网络,即连接的结点信息,Trust提供了若干主网及测试网选项;钱包地址的管理,例如创建、导入、备份、切换等
一、钱包(Wallet)
钱包应用的核心功能之一,就是地址的管理:生成,导入,导出和备份,以及余额查询等功能。在Trust中,主要由EtherKeystore模块封装了以上这些功能,结构如下:
以太坊,或者说区块链当中的唯一标识,就是地址Address,一个地址的产生,简单来说就是一对公钥私钥的产生,以太坊的交易地址为20-byte长的字节串,来源于私钥,通过SHA3 keccak256计算可得出一串40个字符长度的EIP55串,这个串就是我们经常看到的可对外公开的以太坊地址(例如0x5E9c27156a612a2D516C74c7a80af107856F8539)
钱包的私钥相当于账户的户名及密码,私钥的处理和备份需要相当谨慎,一般常用的地址导入及备份方式有keystore、private key、mnemonic三种,不同的钱包偏重不同。Trust开发者单独封装了一个TrustKeystore的库,库中使用了很多加密算法相关的库,例如使用Apple的Security库创建密钥对,使用TrezorCrypto开源库的散列算法、助记词等算法处理私钥等。
Tip:如果在自己的项目中使用TrustKeystore,记得将其pod工程编译优化选项置为Fast,Whole Module Optimization [-O -whole-module-optimization],否则在密钥解析时会非常慢
令牌(ERC20 Token)
区块链的智能合约,可以理解为双方在区块链资产上交易转账时,触发执行的一段代码(合同),我们称它为智能合约。以太坊可以创建任何智能合约,这里的令牌就属于可以表示数字资产的智能合约,而这些数字资产被称为以太坊代币(Token)。Token有许多不同类型的标准,例如ERC20、ERC721、ERC223等。
ERC-20标准是在2015年11月份推出的,它定义了通用的Token代币所应支持的协议和接口标准,使基于以太坊众多的Token可以规范化,方便统一接入和操作。一个Token的基础属性有以下几点:
- total supply //发行总量
- name // 代币名称
- decimal // 数量精度
- symbol // 单位
Trust当中使用开源数据库Realm,对用户手动添加或者从交易记录中提取到的Token对象进行本地存储,如名称、合约地址、单位、精度,以及余额、价格等信息。 同时启动了Timer刷新Token的余额信息。
余额
以太坊余额与Token余额的查询分为两种途径:
- 以太坊:Trust中通过开源第三方库APIKit(网络请求封装库)以及JSONRPCKit(json RPC远程调用库)来进行以太坊节点的RPC接口调用。对应的余额查询接口为“eth_getBalance”。Trust调用的是Infura免费提供的以太坊主网及测试网节点。
Infura,以太坊开发服务平台,由于自建以太坊结点需要花比较多的时间和空间来同步区块,我们可以基于Infura可以简单很多,它免费提供公开以太坊节点和测试节点以调用,去官网只需要提供email,注册后就可得到专属的API地址。
- Token: Trust中对Token的余额查询方式为web3.js,在本地Native层开启一个不可见的wkwebview并且load进来index.html中的JS代码(创建web3对象、provider),通过JavaScriptKit开源库与webview配合完成以太坊智能合约接口的调用。ERC20代币合约的标准余额接口为“balanceOf”。
web3.js是以太坊提供的一个Javascript库,它封装了以太坊的JSON RPC API,提供了一系列与区块链交互的Javascript对象和函数,包括查看网络状态,查看本地账户、查看交易和区块、发送交易、编译/部署智能合约、调用智能合约等,其中最重要的就是与智能合约交互的API。
结构图
二、转账(Transfer)
以太坊以及Token的转账流程相对比较复杂,主要涉及到gas费用的计算、合约调用,签名,提交交易信息。每笔交易首先需要调用“eth_estimateGas”接口获取本次交易的gas费用(用户可修改,理论上gas费越高,交易成交的速度就会越快),之后调用“eth_getTransactionCount”接口获取本次交易nonce值,通过钱包私钥对交易data进行签名,最后调用“eth_sendRawTransaction”接口发送交易信息,至此交易已经提交到以太坊结节,等待矿工执行
需要注意的是,对于Token的转账,交易的Data属性值可以看作Contract ABI的填充,这里来说就是ERC 20合约的Transfer标准方法,方法填参和编码后通过私钥签名放入transaction结构里发送给以太坊节点,在矿工成功挖矿后才会促使以太坊节点解析并执行其中的合约代码,完成Token的转账
三、交易(Transaction)
交易列表及明细是钱包应用不可或缺的一部分,由于以太坊API未提供交易列表的获取接口,Trust是通过第三方服务节点拉取的指定地址的交易列表,当然我们也可以通过etherscan.io平台的API进行交易列表信息的获取,经过简单的注册即可得到专属的API Key。
etherscan.io是 2015 年推出的一个以太坊区块探索和分析的分布式智能合同平台, 由于区块链中的交易信息等数据都是公开透明的 , Etherscan如同探索以太坊的窗口, 用户可以使用其查看自己的交易详情以及以太坊中的任何信息,开发者也可以调用其开发的API接口。
Trust将拉取到的交易信息通过Realm存储到本地数据库中,每次以分页形式拉取最新的交易信息,同时后台运行了一个刷新线程,通过“eth_getTransactionByHash”方法更新的交易状态
四、分布式应用(DAPP)
DAPP在移动APP中的实现,简单来说,就是通过webview注入JS代码,在native端响应请求。Trust在WKWebView初始化时在WKWebViewConfiguration中加入WKUserScript自定义的JS代码以及JS代码中若干的响应方法(例如SignTransaction),native代码通过WKScriptMessageHandler协议响应网页中JS的调用,即完成了网页中点击购买,本地native代码完成支付的整个流程。
开源库
在Trust Wallet这个开源的纯Swift的项目里,用到了20多个开源三方库,APP的组织架构为MVVM,内部以Coordinator为单位进行模块之间的协作和调用,下面是我从Trust内使用的开源库中抽出比较好玩的几个项目介绍下,其实每个库都可以写一篇文章了,我只简单介绍下,深入的话请自行查阅
APIKit
一个轻量的类型安全的网络请求库,两个主要特点,其一是简便快速的调用方式,其二是Request/Response的类型关联。以github的https://api.github.com/rate_limitAPI接口为例,使用APIKit调用的大致流程如下:
-
定义Request协议基类、根url访问地址
protocol GitHubRequest: Request { } extension GitHubRequest { var baseURL: URL { return URL(string: "https://api.github.com")! } }
-
定义返回类型的Model 对象
struct RateLimit { let count: Int let resetDate: Date init?(dictionary: [String: AnyObject]) { guard let count = dictionary["rate"]?["limit"] as? Int else { return nil } guard let resetDateString = dictionary["rate"]?["reset"] as? TimeInterval else { return nil } self.count = count self.resetDate = Date(timeIntervalSince1970: resetDateString) }
}
-
定义具体的GetRateLimitRequest对象,实现Request协议要求的方法,例如具体的path、method、response从JSON到Model的转换等:
struct GetRateLimitRequest: GitHubRequest { typealias Response = RateLimit var method: HTTPMethod { return .get } var path: String { return "/rate_limit" } func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response { guard let dictionary = object as? [String: AnyObject], let rateLimit = RateLimit(dictionary: dictionary) else { throw ResponseError.unexpectedObject(object) } return rateLimit } }
-
发送请求
let request = GetRateLimitRequest() Session.send(request) { result in switch result { case .success(let rateLimit): print("count: \(rateLimit.count)") print("reset: \(rateLimit.resetDate)") case .failure(let error): print("error: \(error)") }
}
是不是so easy?
Moya
Moya与APIKit有着类似的设计思想,都是将网络层相关的对象、参数或者数据抽象化,这样使得网络层结构更加清晰、接口之间更独立和规范,使用起来非常简单。但Moya要比APIKit更强大,它是基于Alamofire库在网络层上的完全封装,开发在应用层可以只单独依赖和调用Moya网络层即可。最大的特点是基于Moya可以很容易的构建出服务器接口组(API Service),独立性更高,方便维护、测试和移植。下面是官方文档给出的结构示意图:
再以github的https://api.github.com/rate_limitAPI接口为例,使用Moya调用的大致流程如下:
-
定义Service及其提供的所有接口
enum GithubService { case rateLimit }
-
实现TargetType协议,明确API的调用path、parameters、method等
// MARK: - TargetType Protocol Implementation extension GithubService: TargetType { var baseURL: URL { return URL(string: "https://api.github.com")! } var path: String { switch self { case . rateLimit: return "/rate_limit" } } var method: Moya.Method { switch self { case .rateLimit: return .get } } var task: Task { switch self { case . rateLimit: // Send no parameters return .requestPlain } } var sampleData: Data { return Data() } var headers: [String: String]? { return ["Content-type": "application/json"] } }
-
创建Service及具体的Request,发送
let provider = MoyaProvider
() provider.request(.rateLimit) { result in switch result { case let .success(moyaResponse): let data = moyaResponse.data let statusCode = moyaResponse.statusCode // do something with the response data or statusCode case let .failure(error): } }
这里的provider就是抽象的Service接口组,可以按照项目的业务划分有一个或者多个,provider是更高层次的划分,APIKit只提供了一个全局的Session服务对象,在Request对象模型上进行了不同业务的划分,这是它们最大的区别之一。
R.swift
App开发项目中存在大量的资源文件或者对象,例如nib、storyboard、image、file、font、color、string等等。引用这些资源基本都是以字符串类型去载入,例如UIImage.init(named: "setting_icon"),如果对象名称改变或者输入有误都将无法正确载入资源,你只能万分小心的copy/paste这些资源名称。
R.swift就是为解决这个问题而生,具有强类型关联、编译错误检查、自动代码填充的功能,即安全又方便。基本原理就是在Xcode每次build期间自动读取解析工程目录内引用的资源文件以及创建的资源文件(例如TableViewCell.nib),将这些资源以代码的形式封装在一个动态生成的R.generated.swift的文件中。例如我在Assets.xcassets中添加了一个settings_icon图标,编译后R.generated.swift中将自动得到下面的代码段:
/// This `R.image` struct is generated, and contains static references to 1 images.
struct image {
/// Image `setting_icon`.
static let setting_icon = Rswift.ImageResource(bundle: R.hostingBundle, name: "setting_icon")
/// `UIImage(named: "setting_icon", bundle: ..., traitCollection: ...)`
static func setting_icon(compatibleWith traitCollection: UIKit.UITraitCollection? = nil) -> UIKit.UIImage? {
return UIKit.UIImage(resource: R.image.setting_icon, compatibleWith: traitCollection)
}
fileprivate init() {}
}
这样,在我们需要使用资源时,不再是
let icon = UIImage(named: "settings-icon")
let font = UIFont(name: "San Francisco", size: 42)
let color = UIColor(named: "indictator highlight")
let viewController = CustomViewController(nibName: "CustomView", bundle: nil)
let string = String(format: NSLocalizedString("welcome.withName", comment: ""), locale: NSLocale.current, "Arthur Dent")
而是
let icon = R.image.settingsIcon()
let font = R.font.sanFrancisco(size: 42)
let color = R.color.indicatorHighlight()
let viewController = CustomViewController(nib: R.nib.customView)
let string = R.string.localizable.welcomeWithName("Arthur Dent")