背景
去年有几个与公链有关的外包项目对接咱们的中心化钱包,项目除传统项目的CRUD外还有个需求就是对接公链上的账户,实现创建账号、到账通知与代转。
业务流程
1584624328(1).png简单描述:
- 注册流程:APP注册账号(1)-》APP后端调用自研钱包创建链上账号(2)-》自研钱包根据账号类型选择本地创建或链上创建(3)
- 转账流程:用户通过第三方钱包转账(A)-》第三方远程调用链上服务转账(B)
- 到账通知与转账流程:自研钱包通过定时任务感知指定账号币变更(C)-》将到账币通过远程调用链上服务转到冷钱包(C2)-》推送消息给APP后端告知用户已转币(C1、D)
- 用户查询流程:用户通过APP查询币是否到账(E)
问题
- 账户安全性:各个公链都是基于区块链技术实现,区块链技术的账号体系是基于公私钥对,拥有账户私钥就具备账号所有控制权,而且还是匿名,根本无法追踪币被谁盗了。
- 实时性:使用APP的普通客户在注册账号时会创建两个账号,一个是链下账号(普通账号)一个链上账号(BTC、ETH、EOS公私钥对)。对用户而言,一旦他通过第三方钱包(火币、imToken)转账给链上账号成功,APP应该能马上感知到。故咱们钱包的实时性得对标第三方钱包。
- 通用性:基本每个外包项目都会对接多个主流币与一个自身平台币(ERC20),故咱们钱包得将对接各个币种公用逻辑抽象出来。
- 搭建全节点:通过区块链技术实现的产品有个特性就行分布式存储,每个存储点都是全量存储。要想获取链上所有数据必须连接全节点
思路
- 账号安全性:通过业务流程来最大降低风险。如上图所示,一旦发现有币到账就里面将币转到冷钱包
- 实时性:根据不同链产生区块的速度去定时轮训数据。基本比第三方钱包先感知币到账,因为咱们监听的公钥少(哈哈)
- 通用性:从表模型设计到项目结构都一层层抽象
- 搭建全节点:尽量使用第三方靠谱的节点,比如ETH就可以通过稍微扩展一下web3j就可以访问官方提供的大部分接口。
设计
数据库设计
1584628276(1).png表描述:
-
用户表(user):每个对接的项目对应一个用户记录
- 存储与项目对接的用户名与密码
- 存储项目冷钱包地址、热钱包私钥(对外转币、创建EOS账号、ETH代币授权、USDT(OMNI)转币的BTC手续费)、链上交易属性配置(ETH的maxGasLimit)
- 存储与项目对接的推送地址
-
账户表(account):存储用户创建的所有链上账户
- 存储账户的链类型(BTC、ETH、EOS)
- 存储账户链类型下的代币类型(BTC包含BTC、USDT;ETH包含ETH、ERC20代币(项目方的代币、USDT))
- 存储公钥、私钥
- 存储已同步区块的高度,防止应用挂了丢失区块
-
账户授权表(eth_approve):如果管理账户到账代币则需要公共账户将其转移,故需要将管理账户授权公共账户转账。此表就是记录授权交易信息,防止重复授权。
- 授权包含四个步骤:(管理账户与公共账户都是链上账户)
- 步骤一:发送授权费用到管理账户; 判断管理账户够余额授权,不够则通过公共账户转币管理账户。发送转账交易失败则进行失败重试
- 步骤二:监听币已达到;转币交易失败则进行失败重试
- 步骤三:发送授权交易;发送授权交易失败则进行失败重试
- 步骤四:监听授权交易已入块;授权交易失败则进行失败重试
- 授权表负责记录各个步骤的执行情况
- 授权包含四个步骤:(管理账户与公共账户都是链上账户)
-
同步链上区块日志表(sync_block_log):记录轮训区块高度的处理情况
- 存储同步块的链类型(BTC、ETH、EOS)、区块高度范围、区块时间范围
- 存储同步状态、失败次数(支持失败最大次数跳过处理)、异常信息
-
同步链上交易日志表(transaction_log):记录轮训区块高度时抓取的与管理账户有关的交易日志及将管理账户的币转到公共账户的情况
- 存储与管理账户有关的入块交易信息(交易Hash、区块类型、代币种类)
- 存储交易类型,如:普通到账交易、冷钱包到账交易、给管理账户转授权币交易、管理账户授权交易
- 存储处理普通到账交易时进行转币到公共账户的状态记录及相应的失败重试状态记录、异常记录
-
用户通知表(use_notify):记录管理账户到账后通知业务系统的处理状况
- 存储管理账户到账的币数量、币种类及其对应的交易日志
- 存储推送业务系统的处理情况,如:失败次数、异常信息。支持失败重试功能
-
平台代转日志表(transfer_log):记录直接由公共账户转币到第三方链上账户的处理状况
- 存储转币账户、到币账户、币种类、数量等
- 存储客户端请求序号,防止重复请求
- 记录装币交易的执行情况,支持失败重试
项目结构设计
1584628996(1).png1584764549(1).png
模块描述:
-
engine:引擎层;将与链有关的自动处理逻辑(读取区块->识别相关交易->到账通知->授权交易->代转币到冷钱包)全部抽象实现,通过SPI的机制调用公链需要定制的逻辑。包含如下五个定时任务(都支持业务失败重试):
- 同步最新区块数据的任务。同步最新区块高度,并根据已同步高度与最新区块高度按照指定高度区间值进行划分,将最新区块范围的自身线程处理,非最新区块范围集合标识为遗漏,交由2线程处理。
- 同步遗漏或者失败区块的任务
- 处理管理账户资金变更的任务。轮训链上交易日志表中已到账的普通转账,将到账币数量转至冷钱包
- 推送管理账户资金到账消息给外部项目的任务。轮训链上交易日志表中已到账的普通转账,推送到账信息给外部项目
- 重试对第三方转账失败的任务
-
service:服务层;与外部项目对接需要用到的服务,需要与链打交道。同样通过SPI机制调用上层链逻辑
- 与外部对接的权限控制服务
- 创建管理账户(链上账户)。需要同时创建不同链的账户
- 给第三方链上账户转账
-
btc/eth/eos:对接公链层;实现engine层提供的SPI接口
- 提供区块配置。如:公链区块平均产生间隔(用来设置定时周期)、交易备注最大长度等
- 提供最新区块信息、查询指定高度区块
- 根据指定公钥查询指定区块范围内的交易信息
- 转账功能实现
-
web:页面层;提供简单页面查询被管理账户在链上的交易状况
API访问权限设计
1584858326(1).pngEOS创建管理账户的设计
1584858182.png扩展web3j访问ETH官方区块信息的接口
Web3j web3j = Web3j.build(new HttpServiceEx("https://api.etherscan.io/api?apikey=xxx"));
import static okhttp3.ConnectionSpec.CLEARTEXT;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.util.Assert;
import org.web3j.protocol.core.Request;
import org.web3j.protocol.core.Response;
import org.web3j.protocol.core.methods.request.Transaction;
import org.web3j.protocol.exceptions.ClientConnectionException;
import org.web3j.protocol.http.HttpService;
import com.ddblock.centwallet.engine.exception.BusinessException;
import com.ddblock.centwallet.engine.exception.SystemException;
import com.ddblock.centwallet.engine.util.JSONUtil;
import com.ddblock.centwallet.eth.exception.NonceException;
import okhttp3.*;
import okhttp3.logging.HttpLoggingInterceptor;
/**
* @author XiaoJia
* @since 2019-05-24 9:22
*/
public class HttpServiceEx extends HttpService {
private static final Logger LOGGER = LogManager.getLogger(HttpServiceEx.class);
/**
* 发送ETH裸交易时出现nonce重复的异常消息
*/
private static final String NONCE_ERROR_MESSAGE = "replacement transaction underpriced";
/**
* 发送ETH裸交易时出现nonce重复的异常消息(转出的账号与值是一样的情况下)
*/
private static final String NONCE_ERROR_MESSAGE2 = "^known transaction: (\\S){64}";
// ------------------------ 基类代码 begin --------------------------
private static final CipherSuite[] INFURA_CIPHER_SUITES = new CipherSuite[] {
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
// Note that the following cipher suites are all on HTTP/2's bad cipher suites list. We'll
// continue to include them until better suites are commonly available. For example, none
// of the better cipher suites listed above shipped with Android 4.4 or Java 7.
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256, CipherSuite.TLS_RSA_WITH_AES_256_GCM_SHA384,
CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA, CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
CipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
// Additional INFURA CipherSuites
CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,
CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA256, CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA256};
private static final ConnectionSpec INFURA_CIPHER_SUITE_SPEC =
new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS).cipherSuites(INFURA_CIPHER_SUITES).build();
/**
* The list of {@link ConnectionSpec} instances used by the connection.
*/
private static final List<ConnectionSpec> CONNECTION_SPEC_LIST = Arrays.asList(INFURA_CIPHER_SUITE_SPEC, CLEARTEXT);
// ------------------------ 基类代码 end --------------------------
private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
private final String url;
private final OkHttpClient httpClient;
public HttpServiceEx(String url) {
this(url, createOkHttpClient());
}
private HttpServiceEx(String url, OkHttpClient httpClient) {
super(url, httpClient);
this.url = url;
this.httpClient = httpClient;
}
private static OkHttpClient createOkHttpClient() {
final OkHttpClient.Builder builder = new OkHttpClient.Builder().connectionSpecs(CONNECTION_SPEC_LIST);
// 添加超时时间
builder.readTimeout(30, TimeUnit.SECONDS);
configureLogging(builder);
return builder.build();
}
private static void configureLogging(OkHttpClient.Builder builder) {
if (LOGGER.isDebugEnabled()) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(LOGGER::debug);
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
builder.addInterceptor(logging);
}
}
@Override
public <T extends Response> T send(Request request, Class<T> responseType) throws IOException {
// 通过request对象,生成Get请求参数
THREAD_LOCAL.set(generateRequestUrl(request));
try {
T response = super.send(request, responseType);
if (response.hasError()) {
String requestMethod = request.getMethod();
String requestParams = JSONUtil.toJSONString(request.getParams());
Integer errorCode = response.getError().getCode();
String errorMessage = response.getError().getMessage();
if (NONCE_ERROR_MESSAGE.equals(errorMessage) || errorMessage.matches(NONCE_ERROR_MESSAGE2)) {
throw new NonceException("执行方法[%s]参数[%s]失败!错误码[%s]错误信息[%s]", requestMethod, requestParams,
errorCode, errorMessage);
} else {
throw new BusinessException("执行方法[%s]参数[%s]失败!错误码[%s]错误信息[%s]", requestMethod, requestParams,
errorCode, errorMessage);
}
}
return response;
} finally {
THREAD_LOCAL.remove();
}
}
@Override
protected InputStream performIO(String request) throws IOException {
String requestUrl = THREAD_LOCAL.get();
// RequestBody requestBody = RequestBody.create(JSON_MEDIA_TYPE, request);
// Headers headers = buildHeaders();
Headers headers = Headers.of(super.getHeaders());
// okhttp3.Request httpRequest = new
// okhttp3.Request.Builder().url(url).headers(headers).post(requestBody).build();
okhttp3.Request httpRequest = new okhttp3.Request.Builder().url(requestUrl).headers(headers).get().build();
okhttp3.Response response = httpClient.newCall(httpRequest).execute();
ResponseBody responseBody = response.body();
if (response.isSuccessful()) {
if (responseBody != null) {
// return buildInputStream(responseBody);
return responseBody.byteStream();
} else {
return null;
}
} else {
int code = response.code();
String text = responseBody == null ? "N/A" : responseBody.string();
throw new ClientConnectionException("Invalid response received: " + code + "; " + text);
}
}
/**
* 生成请求URL
*
* @param request
* 请求对象
*
* @return 请求RUL
*/
private String generateRequestUrl(Request request) {
String method = request.getMethod();
Object[] paramValues = request.getParams().toArray();
StringBuilder sb = new StringBuilder();
sb.append(url).append("&module=proxy&action=").append(method);
switch (method) {
case "eth_blockNumber":
// 无参数
break;
case "eth_getBlockByNumber":
sb.append(generateParamUrl(new String[] {"tag", "boolean"}, paramValues));
break;
case "eth_getUncleByBlockNumberAndIndex":
sb.append(generateParamUrl(new String[] {"tag", "boolean"}, paramValues));
break;
case "eth_getBlockTransactionCountByNumber":
sb.append(generateParamUrl(new String[] {"tag"}, paramValues));
break;
case "eth_getTransactionByHash":
sb.append(generateParamUrl(new String[] {"data"}, paramValues));
break;
case "eth_getTransactionByBlockNumberAndIndex":
sb.append(generateParamUrl(new String[] {"tag", "index"}, paramValues));
break;
case "eth_getTransactionCount":
sb.append(generateParamUrl(new String[] {"address", "tag"}, paramValues));
break;
case "eth_sendRawTransaction":
sb.append(generateParamUrl(new String[] {"hex"}, paramValues));
break;
case "eth_getTransactionReceipt":
sb.append(generateParamUrl(new String[] {"txhash"}, paramValues));
break;
case "eth_call":
// 兼容直接传递交易的情况
Object[] param = new Object[3];
if (paramValues.length == 2 && paramValues[0] instanceof Transaction) {
Transaction trans = (Transaction)paramValues[0];
param[0] = trans.getTo();
param[1] = trans.getData();
param[2] = paramValues[1];
} else {
param = paramValues;
}
sb.append(generateParamUrl(new String[] {"to", "data", "tag"}, param));
break;
case "eth_getCode":
sb.append(generateParamUrl(new String[] {"address", "tag"}, paramValues));
break;
case "eth_getStorageAt":
sb.append(generateParamUrl(new String[] {"address", "position", "tag"}, paramValues));
break;
case "eth_gasPrice":
// 无参数
break;
case "eth_estimateGas":
sb.append(generateParamUrl(new String[] {"to", "value", "gasPrice", "gas"}, paramValues));
break;
default:
throw new SystemException("以太坊区块浏览器暂不支持请求类型[%s]的访问!", method);
}
return sb.toString();
}
/**
* 生成参数请求URL
*
* @param paramNames
* 参数名称集合
* @param paramValues
* 参数值集合
*
* @return 参数请求URL
*/
private String generateParamUrl(String[] paramNames, Object[] paramValues) {
Assert.isTrue(paramNames.length == paramValues.length, "请求参数个数与区块浏览器的参数个数不匹配");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < paramNames.length; i++) {
sb.append("&").append(paramNames[i]).append("=").append(paramValues[i]);
}
return sb.toString();
}
}
网友评论