Skip to content

如何构建预言机服务

正如在这个教程中描述的那样,区块链预言机是向区块链网络提供外部数据的第三方服务或代理。它是区块链和外部世界之间的桥梁,使智能合约能够访问、验证并整合来自区块链外部的数据。具体来说,预言机服务提供外部数据及其Rabin签名,智能合约使用这些数据并验证签名后使用它们。

Rabin签名

Rabin签名是另一种数字签名算法(DSA),用于比特币。它有一个美丽的不对称性,即签名生成计算成本高,但签名验证成本低。因此,我们选择使用Rabin签名以确保预言机提供的外部数据完整性。当预言机提供数据时,它将使用其私钥在链下对数据进行签名。当数据被智能合约使用时,其签名在链上进行验证,这成本低廉。我们在这里不使用内置的checkSig 操作码,因为它只能检查交易数据的签名,而不能检查任意数据。

img

在本节中,我们将介绍如何构建自己的预言机服务。对于后端框架,我们使用NestJS来演示,但您可以自由使用任何熟悉的框架来构建服务。对于Rabin签名部分,我们已经实现了一个库rabinsig,可以直接导入和使用。

这个演示的完整完整代码可以在我们的GitHub仓库中找到。您还可以参考WitnessOnChain的开源预言机服务的代码,了解更多细节。

1. 搭建项目

运行以下命令来创建一个NestJS项目。

bash
npx @nestjs/cli new oracle-demo

然后安装依赖。

bash
cd oracle-demo
npm install
npm install rabinsig

2. 生成签名

一个预言机可能会提供多个数据,每个数据都需要一个签名。我们实现一个公共服务,以便在不同的地方重用和调用。

SigService将从一个环境变量中加载和初始化一个私钥。我们在这个类中添加一个方法sign,它接受一个参数dataBuffer,表示要签名的二进制数据。

ts
import { Rabin, serialize } from 'rabinsig';

export class SigService {
  private rabin = new Rabin();
  // 从环境变量中加载和初始化Rabin私钥
  ...
  sign(dataBuffer: Buffer) {
    const dataHex = dataBuffer.toString('hex');
    const sig = this.rabin.sign(dataHex, this.privKey);
    return { data: dataHex, signature: serialize(sig) };
  }
}

3. 添加API

添加一个时间戳API

为了了解它是如何工作的,我们实现一个简单的时间戳API。我们首先获取当前时间戳,然后将其转换为4字节的Buffer,并以小端格式进行签名。

ts
export function getTimestamp() {
  return Math.trunc(Date.now() / 1000);
}

@Get('/timestamp')
getTimestamp() {
  const timestamp = getTimestamp();
  const data = Buffer.concat([
    toBufferLE(V1Controller.MARKER.TIMESTAMP, 1), // api marker, 1 字节
    toBufferLE(timestamp, 4), // timestamp, 4 字节 LE
  ]);
  const sigResponse = this.rabinService.sign(data);
  return { timestamp, ...sigResponse };
}

这个API的响应如下。

json
{
  "timestamp":1700596603,
  "data":"017b0b5d65",
  "signature":{
    "s":"4fe8bbcdf26...",
    "padding":"0000"
  }
}

对于智能合约,它只需要关注两个部分:datasignature。只有在signature验证通过时,它才应该使用和信任data

API 标记

注意,data中的第一个字节是一个标识符标记,它不仅表示签名的数据是如何序列化的,而且还具有更重要的角色,即区分来自不同接口的数据。

如果没有这个标记,智能合约将无法区分通过data实际来自哪个接口。当预言机有两个接口返回相同长度的签名数据时,攻击者可以将来自另一个接口的数据传递给合约,从而可能导致问题。因此,不同的API应该使用不同的标记值。

添加一个货币价格API

这里我们使用OKX API来获取货币的价格。

首先,包装OKX API。注意如何处理价格值。由于在智能合约中处理浮点数不方便,引入了一个变量decimal,将价格值转换为整数。

ts
/**
 * @param tradingPair 例如 `BSV-USDT`、`BTC-USDC` 等
 * @param decimal 返回价格的小数位数
 * @returns 一个整数,表示交易对的单价,例如返回1234,小数位数为2,表示12.34
 */
async getOkxPrice(tradingPair: string, decimal: number) {
  return axios
    .get(`https://www.okx.com/api/v5/market/ticker?instId=${tradingPair}`)
    .then((r) => Math.trunc(r.data.data[0].last * 10 ** decimal));
}

然后按照获取数据、序列化数据和签名数据的顺序实现预言机API。

ts
@Get('price/:base/:query')
async getPrice(@Param('base') base: string, @Param('query') query: string) {
  // 获取数据
  const tradingPair = `${query.toUpperCase()}-${base.toUpperCase()}`;
  const decimal = 4;
  const price = await this.v1Service.getOkxPrice(tradingPair, decimal);
  // 序列化数据
  const timestamp = getTimestamp();
  const data = Buffer.concat([
    toBufferLE(V1Controller.MARKER.PRICE, 1), // api 标记, 1 字节
    toBufferLE(timestamp, 4), // timestamp, 4 字节 LE
    toBufferLE(price, 8), // price, 8 字节 LE
    toBufferLE(decimal, 1), // decimal, 1 字节
    Buffer.from(tradingPair), // trading pair
  ]);
  // 签名数据
  const sigResponse = this.rabinService.sign(data);
  return { timestamp, tradingPair, price, decimal, ...sigResponse };
}

添加更多API

根据前面的介绍,您可以根据需要为您的预言机添加更多API,例如获取BSV链信息等,这里不再赘述。

4. 在智能合约中使用预言机数据

这个教程中,我们介绍了如何验证和使用智能合约中的预言机数据。

要验证智能合约中的签名,我们需要安装scrypt-ts-lib库。

bash
npm install scrypt-ts-lib

然后在 /src/contracts 文件夹下添加合约。这里我们同样使用 PriceBet 合约。您可以参考 priceBet.e2e-spec.ts 文件获取完整的测试代码。