因业务需求,需要实现交易所自动确认用户的转账。

用户转账地址

用户的转账地址现阶段是直接json-rpc调用getnewaddress接口生成。之后将改为统一批量生成多个地址存入数据库,新用户注册时自动分配,确保每个人的地址唯一。

业务逻辑

目前的逻辑是,通过每分钟轮询一次blockchain.info/latestblock这个接口获取最新的区块高度。
如果区块高度更新,就通过/block-height/${blockNumber}?format=json这个接口获取该区块下的所有交易数据,存入自己的数据库。
然后把所有用户的btc地址历遍,把每个用户的btc地址与最新更新的交易数据匹配,如果发现交易数据中的out等于用户地址,即交易接收人为本站用户,那这比交易就是用户转入btc的交易。

数据库

block表

存储币种和最新区块高度

block.png

btc表

存储btc交易数据

btc.png

代码实现

业务逻辑使用NodeJs实现,其他语言也是可行的,因为要保证代码一直运行,所以要尽可能地catch各种错误,不要让报错停掉代码。
接口文档:blockchain

引入的文件

var Db = require('../Db.js');    //网站数据库
var BDb = require('../BDb.js');    //存储区块交易数据的数据库
var req = require('../request.js');    //http请求
var log = require('../log');    //log日志输出
var COIN = "btc";

checkBlock

该方法确认是否有新区块生产。

这里有一个bestBlockNumber > block的判断操作,bestBlockNumber是获取到的最新区块,block是数据库中保存的最新区块。这个操作是为了保证能记录到所有的区块,不排除一些异常情况比如抛出错误代码停止运行,或是其他网络异常,导致一定时间内代码没有工作,就会漏掉许多区块。blockLog()用于记录指定区块的交易,为了保证服务器压力,这里i <= block + 30限制了最多写入30个区块的限制。当更新的区块达到了最新区块或者达到了30个的限制,就会把区块高度写进数据库里。下次再从这个高度开始。

/**
 * 判断区块是否更新
 * @param block 保存的最新区块
 * @param lock 锁
 */
function checkBlock(block, lock) {
    block = parseInt(block);
    if (lock)
        return;

    clearLog(block);
    req.sget({
        hostname: 'blockchain.info',
        port: 443,
        path: '/latestblock'
    }, data => {
        if (!data) {
            log.success(`Failed to detect ${COIN} update!`);
            return;
        }
        
        bestBlockNumber = data;
        if (bestBlockNumber > block) {
            for (let i = block + 1; i <= bestBlockNumber && i <= block + 30; i++) {
                blockLog(i);
                if (i == bestBlockNumber || i == block + 30) {
                    BDb.query(`UPDATE block SET block=${i} WHERE coinname='${COIN}'`, () => {
                        log.success(`The best block of ${COIN} : ${i}`);
                    })
                }
            }
        } else {
            log.success(`${COIN} block is not updated.`);
        }
    });
}

blockLog

该方法调用blockchain.info/block-height/${blockNumber}?format=json获取每个区块详细的交易记录。

bitcoin钱包提供的rpc方法中,没有一次获得一个区块中所有交易的详细信息如inputsout,仅能获取一个区块中的所有交易hash,要获取详细信息还得再通过交易hash调用rpc,显然是不可取的。因此用到了第三方的api。另外这里要注意的是,/latestblock这个接口获取到的最新区块高度比rpc调用自己钱包获取的区块高度通常会慢一会。

当区块交易数据成功插入数据库后,调用userRechange()userReceivables()两个方法,用于确认用户的转入和转出。

/**
 * 根据区块获取交易信息并写入数据库
 * @param blockNumber 区块高度
 */
function blockLog(blockNumber) {
    req.sget({
        hostname: 'blockchain.info',
        port: 443,
        path: `/block-height/${blockNumber}?format=json`
    }, res => {
        if (!res) {
            log.success(`Get the latest block of ${COIN} failed!`);
            return;
        }
        let sql_value = "";
        let tx = res.blocks[0].tx;
        let time = res.blocks[0].time;
        tx.forEach(e => {
            let out = e.out;
            let hash = e.hash;
            out.forEach(o => {
                let sql_value_s = `(${blockNumber},'${hash}','${o.addr}','${o.value}',${time})`;
                if (sql_value != '')
                    sql_value_s = ',' + sql_value_s;
                sql_value += sql_value_s;
            })
        });
        let sql = `INSERT INTO ${COIN} (\`block\`,\`hash\`,\`addr\`,\`value\`,\`time\`) VALUES `;
        sql += sql_value;
        if (sql_value) {
            BDb.query(sql, () => {
                log.success(`The ${COIN} block has been updated and the updated block is: ${blockNumber}`);
                userRechange(blockNumber);
                userReceivables(blockNumber);
            });
        } else {
            log.success(`There is no data in ${COIN} block ${blockNumber}`);
        }
    });
}

userRechange

该方法历遍网站数据库中所有用户的地址,并把每个地址与对应高度的区块所有交易匹配,匹配成功则进行对应操作,添加转账记录,更改余额之类的。

这里还可以加一个判断,因为btc交易在确认6次之后才被认定为不可更改,即最新区块 - 交易发生区块 > 6。

/**
 * 查询一个区块内是否有用户转入
 * @param blockNumber 区块高度
 */
function userRechange(blockNumber) {
    Db.query(`SELECT btcb,userid FROM user_coin WHERE btcb!='' AND LEFT(btcb,1)!='{'`, r => {
        let addrlist = r;
        if (addrlist !== 'undefined' && addrlist.length > 0) {
            addrlist.forEach(e => {
                let userid = e.userid;
                let addr = e.btcb;
                BDb.query(`SELECT * FROM ${COIN} WHERE addr='${addr}' AND block=${blockNumber}`, r => {
                    if (r.length > 0) {
                        let sql = "INSERT INTO myzr (`userid`,`username`,`coinname`,`txid`,`num`,`mum`,`fee`,`sort`,`addtime`,`endtime`,`status`,`confirmations`,`hash`) VALUES ";
                        let sql_value = '';
                        let num = 0;
                        r.forEach(e => {
                            let value = e.value / 100000000;
                            num += value
                            let sql_value_s = `(${userid},'','${COIN}',0,${value},${value},0,0,${e.time},0,1,0,'${e.hash}')`;
                            if (sql_value != '')
                                sql_value_s = ',' + sql_value_s;
                            sql_value += sql_value_s;
                            log.success(`User ${userid} rechanged ${value} ${COIN} in ${sDate(e.time)}`);
                        });
                        sql += sql_value;
                        Db.query(sql, () => {});
                        Db.query(`UPDATE user_coin SET ${COIN}=${COIN}+${num} WHERE userid=${userid}`, () => {});
                    } else {}
                });
            });
        }
    });
}

userReceivables

该方法历遍网站数据库中所有转出记录,转出是用户先发起存入库中再调用钱包发起转账的。将转出记录中的转出地址与对应高度的区块所有交易匹配,匹配成功则进行对应操作,更改转出记录状态等。

同样可以加入一个判断确认次数的操作,另外这里还有一个${e.addtime - Date.parse(new Date()) / 1000 >= 10800 ? '' :block=$`的操作。如果该转出记录超过6小时了,就把该交易的转出地址与整个库的交易匹配。因为一笔交易很少会出现6个小时没有被处理,很有可能是出现了遗漏。

/**
 * 查询一个区块内是否有用户转出
 * @param blockNumber 区块高度
 */
function userReceivables(blockNumber) {
    Db.query(`SELECT id,hash FROM myzc WHERE coinname='${COIN}' AND \`status\`=0`, res => {
        let myzc = res;
        if (myzc !== 'undefined' && myzc.length > 0) {
            myzc.forEach(e => {
                let id = e.id;
                let hash = e.hash;
                BDb.query(`SELECT * FROM ${COIN} WHERE ${e.addtime - Date.parse(new Date()) / 1000 >= 10800 ? '' : `block=${blockNumber} AND `}\`hash\`='${hash}'`, r => {
                    if (r !== 'undefined' && r.length > 0) {
                        r = r[0];
                        Db.query(`UPDATE myzc SET \`status\`=1 WHERE \`hash\`='${hash}'`, () => {
                            log.success(`${COIN} turn out ID ${id} ${sDate(r.time)} already to account`);
                        });
                    } else {}
                });
            });
        }
    });
}

clearLog

该方法在checkBlock()一开始就被调用过,用于清理8小时前的区块交易,存储区块交易还是很占用空间的。

/**
 * 清理旧数据
 * @param block 区块高度
 */
function clearLog(block) {
    var timestamp = Date.parse(new Date()) / 1000;
    var time = timestamp - 8 * 3600;
    BDb.query(`DELETE FROM ${COIN} WHERE time<=${time}`, () => {
        log.success(`clear ${COIN} before ${time} block`)
        recoveryBlock(block);
    });
}

recoveryBlock

该方法在clearLog()清理完交易数据后调用,目的在于检查已存入的区块是否连贯,即所有区块交易都被记录上。发现缺失的区块就调用blockLog补上。

同样,为了保证服务器压力加了30次的次数限制。

/**
 * 恢复指定区块之前遗漏的区块
 * @param blockNumber 区块高度
 */
function recoveryBlock(blockNumber) {
    BDb.query(`SELECT block FROM \`${COIN}\` WHERE block<=${blockNumber} GROUP BY block`, res => {
        let defect = Array();
        let blockList = Array();
        if (res.length > 0) {
            res.forEach((e, i) => {
                blockList[i] = e.block;
            })
            blockList.push(blockNumber + 1);
            for (let i = 1; i < blockList.length; i++) {
                if (blockList[i] - blockList[i - 1] > 1) {
                    for (let j = 1; j < (blockList[i] - blockList[i - 1]); j++) {
                        defect.push(blockList[i - 1] + j);
                    }
                }
            }
        }
        log.success(`A total of ${defect.length} ${COIN} blocks will be restored.`);
        if (defect.length > 0) {
            for (let i = 0; i < defect.length && i < 30; i++) {
                blockLog(defect[i])
            }
        }
    })

}

sDate

/**
 * 时间戳转时间
 * @param ts 时间戳
 */
function sDate(ts) {
    return new Date(parseInt(ts) * 1000).toLocaleString();
}

app.js

入口文件,每60秒调用一次btc.checkBlock,eth的业务逻辑之后更新。

var Db = require('./Db.js');
var BDb = require('./BDb.js');
var req = require('./request.js');
var rpc = require('./rpc.js');
var btc = require('./coin/btc.js');
var eth = require('./coin/eth.js');
var log = require('./log');

var walvar = [
    'btc',
    'eth'
];

main();

function main(){
    walvar.forEach(e => {
        checkBlock(e);
    })

    setInterval(() => {
	log.success("--------------------------------------------------")
        walvar.forEach(e => {
            checkBlock(e);
        })
    }, 60000);

}

function checkBlock(coin) {
    BDb.query(`SELECT * FROM block WHERE coinname='${coin}'`, r => {
        block = r[0].block;
        lock = r[0].locked;
        switch (coin){
            case 'btc': btc.checkBlock(block, lock);break;
            case 'eth': eth.checkBlock(block, lock);break;
        }
    })
}

总结

目前阶段能想到的不更改钱包应用程序即二次开发的基础上,最好的办法。(〃^∇^)ぇ∧∧∧っ