iOS 以太坊钱包 Trust源码解析

从2017年下半年开始,区块链技术迅速火遍技术圈,投资圈,炒币圈...背景就不多说了,自行查阅。工作需要,近几个月一直在了解学习区块链相关的技术,也尝试着做出了一个基于以太坊的钱包APP,支持Token(ERC20)及DAPP。其实大部分技术基础都来源于github上的一些优秀的开源钱包代码。这篇文章就以 Trust Wallet 这个开源项目为例,描述一下典型的钱包应用都具备哪些功能和支撑的技术点,共同学习。

PS.区块链技术涉及到的知识点很庞杂,一些如区块链、以太坊、智能合约、钱包等基础概念就不细说了,感兴趣的可自行查阅

Trust Wallet

在众多开源的虚拟币钱包项目里,Trust做的算是非常完善和稳定的,代码风格、架构设计,技术栈都很新颖,并且已经在国外的AppStore上架,对应的Android版本也已开源。下面是Trust iOS版本的一些截图,四个Tab页分别为钱包、交易、Token、DAPP浏览器

  1. DAPP浏览器:基于Web浏览器,提供其支持的若干个交易网站,如加密猫游戏等,支持web在移动端进行本地钱包交易的功能
  2. 交易:展示当前钱包地址在以太坊中产生的交易记录,包括Token的交易记录,同时可收发以太币
  3. 通证:展示当前钱包地址在以太坊中,以及发生过交易的Token(ERC20)的余额状态;以太坊Token的转账功能
  4. 设置:切换钱包地址和目标网络,即连接的结点信息,Trust提供了若干主网及测试网选项;钱包地址的管理,例如创建、导入、备份、切换等
Trust

一、钱包(Wallet)

钱包应用的核心功能之一,就是地址的管理:生成,导入,导出和备份,以及余额查询等功能。在Trust中,主要由EtherKeystore模块封装了以上这些功能,结构如下:

wallet

以太坊,或者说区块链当中的唯一标识,就是地址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余额的查询分为两种途径:

  1. 以太坊:Trust中通过开源第三方库APIKit(网络请求封装库)以及JSONRPCKit(json RPC远程调用库)来进行以太坊节点的RPC接口调用。对应的余额查询接口为“eth_getBalance”。Trust调用的是Infura免费提供的以太坊主网及测试网节点。

Infura,以太坊开发服务平台,由于自建以太坊结点需要花比较多的时间和空间来同步区块,我们可以基于Infura可以简单很多,它免费提供公开以太坊节点和测试节点以调用,去官网只需要提供email,注册后就可得到专属的API地址。

  1. 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。

结构图

token

二、转账(Transfer)

以太坊以及Token的转账流程相对比较复杂,主要涉及到gas费用的计算、合约调用,签名,提交交易信息。每笔交易首先需要调用“eth_estimateGas”接口获取本次交易的gas费用(用户可修改,理论上gas费越高,交易成交的速度就会越快),之后调用“eth_getTransactionCount”接口获取本次交易nonce值,通过钱包私钥对交易data进行签名,最后调用“eth_sendRawTransaction”接口发送交易信息,至此交易已经提交到以太坊结节,等待矿工执行

需要注意的是,对于Token的转账,交易的Data属性值可以看作Contract ABI的填充,这里来说就是ERC 20合约的Transfer标准方法,方法填参和编码后通过私钥签名放入transaction结构里发送给以太坊节点,在矿工成功挖矿后才会促使以太坊节点解析并执行其中的合约代码,完成Token的转账

transfer

三、交易(Transaction)

交易列表及明细是钱包应用不可或缺的一部分,由于以太坊API未提供交易列表的获取接口,Trust是通过第三方服务节点拉取的指定地址的交易列表,当然我们也可以通过etherscan.io平台的API进行交易列表信息的获取,经过简单的注册即可得到专属的API Key。

etherscan.io是 2015 年推出的一个以太坊区块探索和分析的分布式智能合同平台, 由于区块链中的交易信息等数据都是公开透明的 , Etherscan如同探索以太坊的窗口, 用户可以使用其查看自己的交易详情以及以太坊中的任何信息,开发者也可以调用其开发的API接口。

Trust将拉取到的交易信息通过Realm存储到本地数据库中,每次以分页形式拉取最新的交易信息,同时后台运行了一个刷新线程,通过“eth_getTransactionByHash”方法更新的交易状态

Transactions

四、分布式应用(DAPP)

DAPP在移动APP中的实现,简单来说,就是通过webview注入JS代码,在native端响应请求。Trust在WKWebView初始化时在WKWebViewConfiguration中加入WKUserScript自定义的JS代码以及JS代码中若干的响应方法(例如SignTransaction),native代码通过WKScriptMessageHandler协议响应网页中JS的调用,即完成了网页中点击购买,本地native代码完成支付的整个流程。

DAPP

开源库

在Trust Wallet这个开源的纯Swift的项目里,用到了20多个开源三方库,APP的组织架构为MVVM,内部以Coordinator为单位进行模块之间的协作和调用,下面是我从Trust内使用的开源库中抽出比较好玩的几个项目介绍下,其实每个库都可以写一篇文章了,我只简单介绍下,深入的话请自行查阅

APIKit

一个轻量的类型安全的网络请求库,两个主要特点,其一是简便快速的调用方式,其二是Request/Response的类型关联。以github的https://api.github.com/rate_limitAPI接口为例,使用APIKit调用的大致流程如下:

  1. 定义Request协议基类、根url访问地址

     protocol GitHubRequest: Request {
     }
         
     extension GitHubRequest {
         var baseURL: URL {
             return URL(string: "https://api.github.com")!
         }
     }
    
  2. 定义返回类型的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)
         }
    

    }

  3. 定义具体的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
         }
     }
    
  4. 发送请求

     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),独立性更高,方便维护、测试和移植。下面是官方文档给出的结构示意图:

Moya

再以github的https://api.github.com/rate_limitAPI接口为例,使用Moya调用的大致流程如下:

  1. 定义Service及其提供的所有接口

     enum GithubService {
         case rateLimit
     }
    
  2. 实现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"]
         }
     }
    
  3. 创建Service及具体的Request,发送

     let provider = MoyaProvider<GithubService>()
     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")

推荐阅读更多精彩内容

  • 【蜕变新生】20170319学习力践行D4:1、因为昨天晚上发生了一点小事情,导致昨晚该做的治疗没有做,半夜让娃尿...
    高汝鑫阅读 58评论 0 0
  • 睡觉的鼻子一滴一滴的感觉有不明物体留下,半夜起来找纸很烦哎,影响睡眠!天干物燥!莫名其妙!
    熊与花阅读 72评论 0 0
  • 四月,因着这临中年日增夜长的体重,便一时心血来潮,动了要走路上班的心思,并且,说走就走了起来。 这晨走,...
    胡美云阅读 228评论 1 5
  • 第一是我对于人生态度的严肃,我喜欢休闲,简简单单,平平淡淡的生活,我怕恶劣人性,复杂的一切人与事物… 第二是我喜欢...
    Sansan388阅读 61评论 0 1