浅析 <路印协议--Loopring> 及整体分析 Relay 源码

3,290 阅读14分钟

作者:林冠宏 / 指尖下的幽灵

前序:

路印协议功能非常之多及强大,本文只做入门级别的分析。

理论部分请细看其白皮书,github.com/Loopring/wh…

实际代码部分:github.com/Loopring/re…


目录

  • 路印协议
  • 一般应用于
  • 作用
  • 模块组成部分
  • 交易流程
  • 代码核心业务逻辑
  • relay源码概述

路印协议

  • 简称Loopring
  • 0xKyber 一样,是区块链应用去中心化交易协议之一,协议明确了使用它来进行买卖交易的行为务必要按照它规定的模式来进行。
  • 从程序的角度去描述的话,它是一份由Go语言编写的可应用于和区块链相关的开源软件。
  • 且外,请注意它不是区块链应用中的智能合约,读者注意区分两者概念。

一般应用于

  • 虚拟货币交易所,交易所有下面例子
    • MtGox
    • Bitfinex
    • 火币网
    • OKEX
    • ...

作用

  • 解决中心化交易存在的一系列问题
    • 缺乏安全
      • 交易所保存用户私钥,黑客攻击后窃走。
      • 体现需要交易所批准,想象下如果交易所人员携款跑路或突然倒闭
    • 缺乏透明度
      • 用户买卖由中心化交易所代替执行,内部具体流程保密
      • 用户资产可能被用作第三方投资
    • 缺乏流动性
      • 交易量多的交易所容易造成市场垄断
      • 即使出过严重事故,却仍然因占巨大市场份额而其他用户不得不继续在该所交易
  • 优化现有区中心话交易的一些问题
    • 缺乏统一标准
    • 流动性差
      • 订单广播网络范围小
      • 订单表成交后更新速度慢
    • 性能问题
      • 导致高额的执行代码支付费用
      • 挖坑延迟
      • 更改/取消订单代价高

模块组成部分

  • 支持向路印网络发送请求的钱包软件
    • APP
    • WEB
  • 路印中继软件 -- Relay
  • 路印区块链智能合约 -- LPSC
  • 路印中继网,由多个运行了路印中继软件的网络节点组成
  • 路印同盟链,布置了LPSC的区块链

交易流程

对照上图共6大步骤的说明及其代码核心业务逻辑

1.协议授权

  • 用户 Y 想交易代币,因此,授权 LPSC 出售数额为 9 的代币 B。此操作不会冻结用户的代币。订单处理期间,用户依然可以自由支配代币。
  • 代码调用逻辑是:钱包向某区块链,例如以太坊的公有链发起json-rpc请求,根据请求中的合约地址address合约ABI信息找到对应的LPSC合约后,再根据methodName找到对应的的接口方法,这些接口方法当然是遵循ERC20标准的。请求授权出售Y账户9个B代币。

2. 订单创建

  • 钱包APP或网页应用中,显示由网络中介,例第三方API接口https://api.coinmarketcap.com提供 代币 B 和代币 C 之间的当前汇率和订单表。用户根据这些信息,设置好自己的买卖代币及其相关数量,例如:卖10ETH,买50EOS。然后创建好这个订单请求,订单中还有其他信息。最后订单被用户Y的私钥加密,也就是签名后发给中继点软件 --- relay

  • 代码调用逻辑是:钱包客户端可以采用Http请求调用第三方API接口或使用其它方式来获取ticker--24小时市场变化统计数据和各代币的价格信息之后,再通过UI界面组合显示订单表和汇率。用户设置好自己的订单信息后和签名后,通过josn-rpc请求向relay发起订单请求。

  • 订单签名步骤

    • 文档
    • 使用Keccak-256 算法对这个字节数组做散列计算得到订单的Hash
    • Secp256k1签名算法对得到的Hash进行签名得到Sig
    • Sig的0-32 位转换成16进制字符串赋值给Order的r
    • Sig的32-64 位转换成16进制字符串赋值给Order的s
    • Sig的64位加上27转换成整型Order的v

3.订单广播

  • 钱包向单个或多个中继发送订单及其签名,中继随之更新辖下公共订单表。路印协议不限制订单表架构,允许“先到先得”模式;中继可以自行选择订单表设计。

  • 代码调用逻辑是:客户端向单个或多个relay发送order request后,relay接收到订单后,再各自向已知的其它relay进行广播,广播的技术点在relay源码中的gateway部分可以看出使用的是IPFS--点对点的分布式版本文件系统技术。那么这些relay点它们组成的就是上面所说的路印中继网。随后各relay进行各自的订单表refresh,这就保证了统一。表的设计是可以自定义的,例如字段,数据库引擎的选择等。

4.流动性共享

  • 这部分已经附属解析到第三点中的互相广播部分。
  • 此外,补充两点
    • 节点有权选择是否及如何交流,我们可以通过修改源码来进行各种限制
    • 这部分有个核心点--接收广播后的表更新算法设计,如何达到高速处理杜绝误差回滚

5.环路撮合(订单配对)

  • 环路矿工撮合多笔订单,以等同或优于用户开出的汇率满足部分或全部订单数额。路印协议之所以能够保证任何交易对之间的高流动性,很大程度上得益于环路矿工。如果成交汇率高于用户 Y 的出价,环路中所有订单皆可共享个中利润。而作为报酬,环路矿工可以选择收取部分利润(分润,同时向用户支付 LRx),或收取原定的LRx 手续费。

  • 原定手续费LRx 的是在订单创建的时候,由客户端设置的

  • 环路数学符号

    • 环路矿工撮合多笔订单,以等同或优于用户开出的汇率满足部分或全部订单数额。它的表达式就是:Ri->j * Rj->i >= 1
    • 此外,对于某订单中,部分被交易的。例如卖10A买2B,结果卖出了4A,那么默认必然是买入了 (2/5)B。因为。订单兑换率恒定 除非订单完全成交:Ri->j * Rj->i = 1,否则部分卖买出的比例兑换率等同于原始的兑换率。10/2=4/y
  • 代码调用逻辑是:miner部分的代码,和relay在同一个项目中。在relay处理完订单之后,miner会去去订单表拿取订单进行撮合。形成最优环,也就是订单成功配对,miner这层会进行对应的数学运算。

6. 验证及结算

  • 这部分是LPSC处理的。
    • LPSC 接收订单环路后会进行多项检查,验证环路矿工提供的数据,例如各方签名。
    • 决定订单环路是否可以部分或全部结清(取决于环路订单的成交汇率和用户钱包中的代币余额)。
    • 如果各项检查达标,LPSC会通过原子操作将代币转至用户,同时向环路矿工和钱包支付手续费。
    • LPSC 如果发现用户 Y 的余额不足,会采取缩减订单数额。
    • 一旦足够的资金存入地址,订单会自动恢复至原始数额。而取消订单则需要单向手动操作且不可撤销。
    • 上面的存入地址中的地址指的是,用户在区块链中的账户地址。
  • 代码调用逻辑是:relayminer的环路数据,和第一点一样,通过json-rpc请求到公链中的LPSC合约,让它进行处理。

relay源码概述

就我所分析的最新的relay源码,它内部目前是基于ETH公有链作为第一个开发区块链平台。内部采用里以太坊Go源码包很多的方法结构体,json-rpc目前调用的命令最多的都是Geth的。

可能是考虑到ETH的成熟和普及程度,所以选择ETH作为第一个开发区块链平台。但路印协议并不是为ETH量身定做的,它可以在满足条件的多条异构区块链上得以实施。后续估计会考虑在EOS,ETC等公有链上上进行开发。

程序的入口

采用了cli模式,即提供了本地命令行查询。也提供了外部的API。

--relay
--|--cmd
--|--|--lrc
--|--|--|--main.go

func main() {
    app := utils.NewApp()
    app.Action = startNode // 启动一个中继节点
    ...
}

节点的初始化与启动

func startNode(ctx *cli.Context) error {

	globalConfig := utils.SetGlobalConfig(ctx) // 读取配置文件并初始化
	// 日志系统初始化
	// 对系统中断和程序被杀死事件信号的注册
	n = node.NewNode(logger, globalConfig) // 初始化节点
	//...
	n.Start() // 启动节点
	//...
	return nil
}

配置文件位置在

--relay
--|--config
--|--|--relay.toml
--|--|--其它

relay.toml 内部可配置的项非常多,例如硬存储数据库MySQL配置信息的设置等。

初始化节点,各部分的的介绍请看下面代码的注释

func NewNode(logger *zap.Logger, globalConfig *config.GlobalConfig) *Node {
    // ...
    // register
    n.registerMysql() // lgh:初始化数据库引擎句柄和创建对应的表格,使用了 gorm 框架
    cache.NewCache(n.globalConfig.Redis) // lgh:初始化Redis,内存存储三方框架
    
    util.Initialize(n.globalConfig.Market) // lgh:设置从 json 文件导入代币信息,和市场
    n.registerMarketCap() // lgh: 初始化货币市值信息,去网络同步
    
    n.registerAccessor()  // lgh: 初始化指定合约的ABI和通过json-rpc请求eth_call去以太坊获取它们的地址,以及启动了定时任务同步本地区块数目,仅数目
    
    n.registerUserManager() // lgh: 初始化用户白名单相关操作,内存缓存部分基于 go-cache 库,以及启动了定时任务更新白名单列表
    
    n.registerOrderManager() // lgh: 初始化订单相关配置,含内存缓存-redis,以及系列的订单事件监听者,如cancel,submit,newOrder 等
    n.registerAccountManager() // lgh: 初始化账号管理实例的一些简单参数。内部主要是和订单管理者一样,拥有用户交易动作事件监听者,例如转账,确认等
    n.registerGateway() // lgh:初始化了系列的过滤规则,包含订单请求规则等。以及 GatewayNewOrder 新订单事件的订阅
    n.registerCrypto(nil) // lgh: 初始化加密器,目前主要是Keccak-256
    
    if "relay" == globalConfig.Mode {
    	n.registerRelayNode()
    } else if "miner" == globalConfig.Mode {
    	n.registerMineNode()
    } else {
    	n.registerMineNode()
    	n.registerRelayNode()
    }
    
    return n
}

func (n *Node) registerRelayNode() {
    n.relayNode = &RelayNode{}
    n.registerExtractor()
    n.registerTransactionManager() // lgh:事务管理器
    n.registerTrendManager()   // lgh: 趋势数据管理器,市场变化趋势信息
    n.registerTickerCollector() // lgh: 负责统计24小时市场变化统计数据。目前支持的平台有OKEX,币安
    n.registerWalletService() // lgh: 初始化钱包服务实例
    n.registerJsonRpcService()// lgh: 初始化 json-rpc 端口和绑定钱包WalletServiceHandler,start 的时候启动服务
    n.registerWebsocketService() // lgh: 初始化 webSocket
    n.registerSocketIOService()
    txmanager.NewTxView(n.rdsService)
}

func (n *Node) registerMineNode() {
    n.mineNode = &MineNode{}
    ks := keystore.NewKeyStore(n.globalConfig.Keystore.Keydir, keystore.StandardScryptN, keystore.StandardScryptP)
    n.registerCrypto(ks)
    n.registerMiner()
}

从上面的各个register点入手分析。有如下结论

  • 整体来说,relay的内部代码的通讯模式是基于:事件订阅--事件接收--事件处理 的。
  • relay 采用的硬存储数据库是分布式数据库Mysql,代码中使用了gorm框架。在registerMysql 做了表格的创建等工作
  • 内存存储方面有两套
    • 基于 Redis
    • 基于 go-cache
  • 在导入代币信息,和市值信息的部分存在一个问题点:配置文件中的市场市值数据获取的第三方接口coinmarketcap已经在其官网发表了声明,v1版本的API将于本年11月30日下线,所以,relay这里默认的配置文件中下面的需要改为v2版本的。

[market_cap]
        base_url = "https://api.coinmarketcap.com/v1/ticker/?limit=0&convert=%s"
        currency = "USD"
        duration = 5
        is_sync = false
  • OrderManagerAccountManager 中注册的Event 事件,主要被触发的点在socketio.go 中,对应上面谈到的gateway模块中负责接收IPFS通讯的广播。在接收完后,才会再分发下去,进行触发事件处理。

    --relay
    --|--gateway
    --|--|--socketio.go
    
    func (so *SocketIOServiceImpl) broadcastTrades(input eventemitter.EventData) (err error) {
        // ...
        v.Emit(eventKeyTrades+EventPostfixRes, respMap[fillKey])
        // ...
    }
    
  • 新订单事件的触发步骤分两层

    • gateway.go 里面的eventemitter.GatewayNewOrderIPFS分发
    • OrderManager 里面的 eventemitter.NewOrder
      • gateway.go接收到GatewayNewOrder之后分发。
      • 客户端调用WalletService 的 API SubmitOrder 后触发
  • relay节点模式有3种

    • 单启动 relay 中继节点

    • 单启动 miner 矿工节点

    • 双启动,这是默认的形式

      if "relay" == globalConfig.Mode {
      	n.registerRelayNode()
      } else if "miner" == globalConfig.Mode {
      	n.registerMineNode()
      } else {
      	n.registerMineNode()
      	n.registerRelayNode()
      }
      
  • relay--中继节点 提供了给客户端的API主要是WalletService钱包的。前缀方法名是: loopring

    • 支持 json-rpc 的格式调用

    • 只是Http-GET & POST 的形式调用

      func (j *JsonrpcServiceImpl) Start() {
          handler := rpc.NewServer()
          if err := handler.RegisterName("loopring", j.walletService); err != nil {
          	fmt.Println(err)
          	return
          }
          var (
          	listener net.Listener
          	err      error
          )
          if listener, err = net.Listen("tcp", ":"+j.port); err != nil {
          	return
          }
          //httpServer := rpc.NewHTTPServer([]string{"*"}, handler)
          httpServer := &http.Server{Handler: newCorsHandler(handler, []string{"*"})}
          //httpServer.Handler = newCorsHandler(handler, []string{"*"})
          go httpServer.Serve(listener)
          log.Info(fmt.Sprintf("HTTP endpoint opened on " + j.port))
          return
      }
      
  • Miner--矿工节点,主要提供了订单环路撮合的功能,可配置有如下的部分。

    [miner]
        ringMaxLength = 4  // 最大的环个数
        name = "miner1"
        rate_ratio_cvs_threshold = 1000000000000000
        subsidy = 1.0
        walletSplit = 0.8
        minGasLimit = 1000000000
        maxGasLimit = 100000000000 // 邮费最大值
        feeReceipt = "0x750aD4351bB728ceC7d639A9511F9D6488f1E259"
        [[miner.normal_miners]]
            address = "0x750aD4351bB728ceC7d639A9511F9D6488f1E259"
            maxPendingTtl = 40
            maxPendingCount = 20
            gasPriceLimit = 10000000000
        [miner.TimingMatcher]
        		round_orders_count=2
        		duration = 10000  // 触发一次撮合动作的毫秒数
        		delayed_number = 10000
        		max_cache_rounds_length = 1000
        		lag_for_clean_submit_cache_blocks = 200
        		reserved_submit_time = 45
        		max_sumit_failed_count = 3
    
    • 矿工节点的启动分两部分:

      • 匹配者,负责订单撮合
      • 提交者,负责订单结果的提交与其他处理
      func (minerInstance *Miner) Start() {
          minerInstance.matcher.Start()
          minerInstance.submitter.start()
      }
      
    • miner 自己拥有一个计费者。在匹配者matcher定时从ordermanager中拉取n条order数据进行匹配成环,如果成环则通过调用evaluator进行费用估计,然后提交到submitter进行提交到以太坊

      evaluator := miner.NewEvaluator(n.marketCapProvider, n.globalConfig.Miner)
      
    • 匹配者 matcher.Start()

      func (matcher *TimingMatcher) Start() {
      	matcher.listenSubmitEvent() // lgh: 注册且监听 Miner_RingSubmitResult 事件,提交成功或失败或unknown 后,都从内存缓存中删除该环
      	matcher.listenOrderReady() // lgh: 定时器,每隔十秒,进行以太坊,即Geth同步的区块数和 relay 本地数据库fork是false的区块数进行对比,来控制匹配这 matcher 是否准备好,能够进行匹配
      	matcher.listenTimingRound() // lgh: 开始定时进行环的撮合,受上面的 orderReady 影响
      	matcher.cleanMissedCache() // lgh: 清除上一次程序退出前的错误内存缓存
      }
      
      • Geth同步的区块数和 relay 本地数据库fork是false的区块数进行对比
      if err = ethaccessor.BlockNumber(&ethBlockNumber); nil == err {
      	var block *dao.Block
      	// s.db.Order("create_time desc").Where("fork = ?", false).First(&block).Error
      	if block, err = matcher.db.FindLatestBlock(); nil == err { block.BlockNumber, ethBlockNumber.Int64())
      		if ethBlockNumber.Int64() > (block.BlockNumber + matcher.lagBlocks) {
      			matcher.isOrdersReady = false
      		} else {
      			matcher.isOrdersReady = true
      		}
      	}
      }
      ...
      
      • matcher.isOrdersReady 控制撮合的开始
      if !matcher.isOrdersReady {
      	return
      }
      ...
      m.match()
      ...
      
      • TimingMatcher.match 方法是整个订单撮合的核心。在其成功撮合后,会发送eventemitter.Miner_NewRing 新环事件,告诉订阅者,撮合成功
    • 提交者 submitter.start()。提交者,主要有一个很核心的步骤: 订阅后并监听 Miner_NewRing 事件,然后提交到以太坊,再更新本地环数据表。代码如下

      // listenNewRings()
      txHash, status, err1 := submitter.submitRing(ringState) // 提交到以太坊
      ...
      submitter.submitResult(...) // 触发本地的 update
      
      func (submitter *RingSubmitter) submitRing(...) {
      	...
      	if nil == err {
      		txHashStr := "0x"
      		//  ethaccessor.SignAndSendTransaction 提交函数
      		txHashStr, err = ethaccessor.SignAndSendTransaction(ringSubmitInfo.Miner, ringSubmitInfo.ProtocolAddress, ringSubmitInfo.ProtocolGas, ringSubmitInfo.ProtocolGasPrice, nil, ringSubmitInfo.ProtocolData, false)
      		...
      		txHash = common.HexToHash(txHashStr)
      	} 
      	...
      }
      

至此,我们有了一个整体的概念。对照上面的交易流程图。从客户端发起订单,都relay处理后,最后提交给区块链(例以太坊公链),到最终的交易完成。relay 源码内的各个模块是各司其责的。

Relay钱包路印协议之间的桥接,向上和钱包对接,向下和Miner对接。给钱包提供API,给Miner提供订单,内部维护订单池。

miner一方面撮合订单,另一方面和LPSC交互。而LPSC则和其所在公链交互。