diff --git a/.env.development b/.env.development index d40b18a..24126bd 100644 --- a/.env.development +++ b/.env.development @@ -18,6 +18,21 @@ ALCHEMY_API_BASE="https://openapi-test.alchemypay.org" ALCHEMY_PAGE_BASE="https://ramptest.alchemypay.org" ALCHEMY_PAY_CB_URL="https://wallet.cebggame.com" +CHAIN_CLIENT_URL=http://127.0.0.1:3006 #cryptocompare api key from metamask^_^ -CRYPTOCOMPARE_API_KEY=d1ec8cd68228095debc9db2dca45771b905ce1f27f522ebfef025c236f4aef3b \ No newline at end of file +CRYPTOCOMPARE_API_KEY=d1ec8cd68228095debc9db2dca45771b905ce1f27f522ebfef025c236f4aef3b + +# alchemy 相关配置 +AVAILABLE_NETWORK=arbitrum|bsc +AVAILABLE_TOKENS=cec|ceg +ARBITRUM_CHAIN_ID=421163 +ARBITRUM_CEC_ADDRESS='0xaa34B79A0Ab433eaC900fB3CB9f191F5Cd27501D' +ARBITRUM_CEG_ADDRESS='0xaa34B79A0Ab433eaC900fB3CB9f191F5Cd27501D' +ARBITRUM_WALLET='' +BSC_CHAIN_ID=97 +BSC_CEC_ADDRESS='0xaa34B79A0Ab433eaC900fB3CB9f191F5Cd27501D' +BSC_CEG_ADDRESS='0xaa34B79A0Ab433eaC900fB3CB9f191F5Cd27501D' +BSC_WALLET='' +# 链端转账回调地址 +PAY_TRANSFER_CB_URL='' \ No newline at end of file diff --git a/src/config/config.ts b/src/config/config.ts index 1f4b589..18e54e0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -30,7 +30,18 @@ const publicKey = ` ${process.env.API_TOKEN_SECRET_PUBLIC} -----END PUBLIC KEY----- ` - +let networks = process.env.AVAILABLE_NETWORK.split('|') +let tokenList = process.env.AVAILABLE_TOKENS.split('|') +let chainCfgs = {} +for (let network of networks) { + let data = { chainId: 0, wallet: '', tokens: {} } + data.chainId = parseInt(process.env[`${network.toUpperCase()}_CHAIN_ID`]) + data.wallet = process.env[`WALLET_${network.toUpperCase()}`] + for (let sub of tokenList) { + data.tokens[sub] = process.env[`ADDRESS_${sub.toUpperCase()}_${network.toUpperCase()}`] + } + chainCfgs[network] = data +} let baseConfig = { api: { port: parseInt(process.env.API_PORT), @@ -42,6 +53,7 @@ let baseConfig = { db_main: process.env.DB_MAIN, db_second: process.env.DB_SECOND, + chainCfgs: chainCfgs, } export default baseConfig diff --git a/src/controllers/alchemy.controller.ts b/src/controllers/alchemy.controller.ts index bfe52b7..fe156d8 100644 --- a/src/controllers/alchemy.controller.ts +++ b/src/controllers/alchemy.controller.ts @@ -2,7 +2,7 @@ import logger from 'logger/logger' import BaseController from 'common/base.controller' import { ZError } from 'common/ZError' import { router } from 'decorators/router' -import { createPageSign, queryPrice, refreshToken } from 'service/alchemy.svr' +import { createPageSign, queryFiat, queryPrice, refreshToken } from 'service/alchemy.svr' import { generateKVStr } from 'utils/net.util' import { PayRecord, PayStatus } from 'modules/PayRecord' import { PriceSvr } from 'service/price.svr' @@ -12,7 +12,17 @@ class AlchemyController extends BaseController { @router('post /pay/alchemy/buy') async beginPay(req, res) { const user = req.user - const { chain, currency, address } = req.params + const { network, crypto, address, fiat, fiatAmount, country } = req.params + if (fiat || fiatAmount || country) { + if (!fiat || !fiatAmount || !country) { + throw new ZError(11, 'fiat, fiatAmount and country must be provided') + } + } + if (network || crypto) { + if (!network || !crypto) { + throw new ZError(12, 'network and crypto must be provided') + } + } const tokenResult = await refreshToken(user.emailReal || user.email) console.log(tokenResult) if (!tokenResult.success || tokenResult.returnCode !== '0000') { @@ -20,7 +30,10 @@ class AlchemyController extends BaseController { throw new ZError(10, 'fetch pay token error') } const { id, email, accessToken } = tokenResult.data - let record = new PayRecord({ account: user.id, address, chain, currency }) + let record = new PayRecord({ account: user.id, address, network, crypto }) + if (fiat) record.fiat = fiat + if (fiatAmount) record.fiatAmount = fiatAmount + if (country) record.country = country await record.save() const merchantOrderNo = record.id let dataOrign: any = { @@ -30,8 +43,11 @@ class AlchemyController extends BaseController { showTable: 'buy', merchantOrderNo, } - if (chain) dataOrign.network = chain - if (currency) dataOrign.crypto = currency + if (network) dataOrign.network = network + if (crypto) dataOrign.crypto = crypto + if (fiat) dataOrign.fiat = fiat + if (fiatAmount) dataOrign.fiatAmount = fiatAmount + if (country) dataOrign.country = country let dataSign: any = { appId: process.env.ALCHEMY_APPID, address, @@ -49,10 +65,16 @@ class AlchemyController extends BaseController { @router('post /pay/alchemy/crypto_price') async queryCryptoPrice(req, res) { - let { token, chain, currency } = req.params + let { token, chain, currency, env } = req.params if (!token || !chain) { throw new ZError(11, 'token or network not found') } + if (env.toLowerCase() === 'dev' && (token.toLowerCase() === 'ceg' || token.toLowerCase() === 'cec')) { + return { price: 1 } + } + if (token.toLowerCase() === 'ceg') { + return { price: 1 } + } let data = { crypto: token, network: chain, @@ -61,4 +83,13 @@ class AlchemyController extends BaseController { let result = await new PriceSvr().fetchPrice(data) return { price: result } } + + @router('get /pay/alchemy/fait_list') + async cryptoList(req, res) { + let result = await queryFiat() + if (!result.success) { + throw new ZError(10, result.returnMsg || 'fetch fiat list error') + } + return result.data + } } diff --git a/src/controllers/alchemyout.controller.ts b/src/controllers/alchemyout.controller.ts index bef4738..682eb92 100644 --- a/src/controllers/alchemyout.controller.ts +++ b/src/controllers/alchemyout.controller.ts @@ -1,10 +1,20 @@ - import logger from 'logger/logger' import BaseController, { ROLE_ANON } from 'common/base.controller' import { ZError } from 'common/ZError' import { role, router } from 'decorators/router' -import { checkPayResultSign } from 'service/alchemy.svr' +import { checkPayResultSign, checkSimpleSign } from 'service/alchemy.svr' import { PayRecord, PayStatus } from 'modules/PayRecord' +import { TransferQueue } from 'queue/transfer.queue' + +let errorRes = function (msg: string) { + return { + direct: 1, + data: null, + success: false, + returnCode: '9999', + returnMsg: msg, + } +} /** * for Alchemy call */ @@ -22,7 +32,7 @@ class AlchemyOutController extends BaseController { logger.info(`alchemy callback record not found`) throw new ZError(12, 'alchemy callback record not found') } - if (record.status !== PayStatus.PENDING) { + if (record.status !== PayStatus.PENDING && record.status !== PayStatus.TRANSFERING) { logger.info(`alchemy callback record status error`) throw new ZError(13, 'alchemy callback record status error') } @@ -34,8 +44,8 @@ class AlchemyOutController extends BaseController { } record.outOrderId = orderNo - record.chain = network - record.currency = crypto + record.network = network + record.crypto = crypto record.outData = req.params record.status = status == 'PAY_SUCCESS' ? PayStatus.SUCCESS : PayStatus.FAIL await record.save() @@ -51,20 +61,29 @@ class AlchemyOutController extends BaseController { async queryToken(req, res) { const { crypto } = req.params const { appId, timestamp, sign } = req.headers + if (!crypto) { + return errorRes('params mismatch') + } + if (!appId || !timestamp || !sign) { + return errorRes('headers mismatch') + } + if (!checkSimpleSign(req.headers)) { + return errorRes('sign error') + } let result = { direct: 1, data: { - price: "1.0", + price: '1.0', networkList: [ { - network: "ETH", - networkFee: "1.21" - } - ] - }, + network: 'ETH', + networkFee: '1.21', + }, + ], + }, success: true, - returnCode: "0000", // false: 9999 - returnMsg: "in amet", + returnCode: '0000', // false: 9999 + returnMsg: 'in amet', } return result } @@ -76,16 +95,37 @@ class AlchemyOutController extends BaseController { @role(ROLE_ANON) @router('post /pay/out/alchemy/distribute') async distributeToken(req, res) { - const { appId, timestamp, sign } = req.headers const { orderNo, crypto, network, address, cryptoAmount, cryptoPrice, usdtAmount } = req.params + const { appId, timestamp, sign } = req.headers + if (!orderNo || !crypto || !network || !address || !cryptoAmount || !cryptoPrice || !usdtAmount) { + return errorRes('params mismatch') + } + if (!appId || !timestamp || !sign) { + return errorRes('headers mismatch') + } + if (!checkSimpleSign(req.headers)) { + return errorRes('sign error') + } + + let record = await PayRecord.findByRecordId(orderNo) + if (!record) { + return errorRes('orderNo not found') + } + if (record.crypto != crypto || record.network != network || record.address != address) { + return errorRes('params mismatch') + } + record.cryptoAmount = cryptoAmount + record.cryptoPrice = cryptoPrice + record.usdtAdmount = usdtAmount + await record.save() + new TransferQueue().addTask(record) let result = { direct: 1, - data: null, + data: null, success: true, - returnCode: "0000", // false: 9999 - returnMsg: "in amet", + returnCode: '0000', // false: 9999 + returnMsg: 'in amet', } res.send(result) } - } diff --git a/src/controllers/internal.controller.ts b/src/controllers/internal.controller.ts new file mode 100644 index 0000000..9b70403 --- /dev/null +++ b/src/controllers/internal.controller.ts @@ -0,0 +1,83 @@ +import { ZError } from 'common/ZError' +import BaseController, { ROLE_ANON } from 'common/base.controller' +import { role, router } from 'decorators/router' +import logger from 'logger/logger' +import { PayRecord, PayRecordClass, PayStatus } from 'modules/PayRecord' +import { TransferRecord, TransferRecordClass } from 'modules/TransferRecord' +import { hmacsha256 } from 'utils/security.util' +import { DocumentType } from '@typegoose/typegoose' +import { queryPrice, updateOrderStatus } from 'service/alchemy.svr' + +const calcHash = function (data: any) { + let signStr = JSON.stringify(data) + return hmacsha256(signStr, process.env.HASH_SALT) +} + +const notify = async function (record: DocumentType, subTask: DocumentType) { + let data: any = { + orderNo: record.outOrderId, // AlchemyPay订单号 + crypto: record.crypto, // 用户购买的数字货币 + cryptoAmount: record.cryptoAmount, //用户的提币数量 + cryptoPrice: record.cryptoPrice, // 实时的价格/USDT CEG锚定USDT + txHash: record.txHash, // 给用户转账的hash + network: record.network, // 用户购买的数字货币对应的网络 + // networkFee: record.networkFee, // 网络费用/USDT + address: record.address, // 用户的提币地址 + status: 'SUCCESS', // SUCCESS/FAIL + } + try { + let priceData = await queryPrice({ gas: subTask.gas }) + data.networkFee = priceData.leagel + let result = await updateOrderStatus(data) + logger.info('update transfer status success::', JSON.stringify(result)) + if (result.success) { + subTask.status = 8 + await subTask.save() + } + } catch (err) { + logger.error(`notify alchemy error:: ${err.message}`) + } +} + +export default class InternalController extends BaseController { + @role(ROLE_ANON) + @router('post /api/internal/update_task') + async updateTaskInfo(req) { + let { sign, id, result, successCount, errorCount, gas, gasPrice, hashList } = req.params + if (!sign) { + throw new ZError(10, 'sign not found') + } + let hash = calcHash({ id, result, successCount, errorCount, hashList }) + console.log(hash, sign) + if (sign !== hash) { + throw new ZError(11, 'sign not match') + } + logger.info(`task report:: ${id}|${result}|${successCount}|${errorCount}|${JSON.stringify(hashList)}}`) + if (!id) { + throw new ZError(11, 'taskId not found') + } + let record = await TransferRecord.findById(id) + if (!record) { + throw new ZError(12, 'TransferRecord not found') + } + let task = await PayRecord.findById(record.recordId) + if (!task) { + throw new ZError(13, 'PayRecord not found') + } + if (result === 2) { + record.status = 9 + record.gas = gas + record.gasPrice = gasPrice + record.hashList = hashList + task.txHash = hashList[0] + task.status = PayStatus.TRANSFERED + setImmediate(notify.apply(this, [task])) + } else { + record.status = 10 + task.status = PayStatus.TRANSFER_FAIL + } + await record.save() + await task.save() + return {} + } +} diff --git a/src/modules/PayRecord.ts b/src/modules/PayRecord.ts index 8795274..e64f649 100644 --- a/src/modules/PayRecord.ts +++ b/src/modules/PayRecord.ts @@ -1,4 +1,4 @@ -import { getModelForClass, index, modelOptions, mongoose, prop, Severity } from '@typegoose/typegoose' +import { getModelForClass, index, modelOptions, mongoose, prop, ReturnModelType, Severity } from '@typegoose/typegoose' import { dbconn } from 'decorators/dbconn' import { BaseModule } from './Base' @@ -13,16 +13,20 @@ export enum PayType { export enum PayStatus { PENDING = 0, - SUCCESS = 1, - FAIL = 2, + TRANSFERING = 1, //只有国库模式才会有该状态 + TRANSFERED = 2, //只有国库模式才会有该状态 + SUCCESS = 9, + TRANSFER_FAIL = 98, // 转账错误 + FAIL = 99, } @dbconn() +@index({ outOrderId: 1 }, { unique: true, partialFilterExpression: { outOrderId: { $exists: true } } }) @modelOptions({ schemaOptions: { collection: 'pay_record', timestamps: true }, options: { allowMixed: Severity.ALLOW }, }) -class PayRecordClass extends BaseModule { +export class PayRecordClass extends BaseModule { @prop({ enum: PayPlatEnum, default: PayPlatEnum.ALCHEMY }) public channel!: PayPlatEnum @@ -36,10 +40,30 @@ class PayRecordClass extends BaseModule { public address: string @prop() - public chain: string + public network?: string @prop() - public currency?: string + public crypto?: string + + // 法币 + @prop() + public fiat?: string + // 法币数量 + @prop() + public fiatAmount?: string + // 加密货币数量 + @prop() + public cryptoAmount?: string + + // 加密货币价格 + @prop() + public cryptoPrice?: string + // 该笔交易渠道会给我们多少usdt + @prop() + public usdtAdmount?: string + // 国家 + @prop() + public country?: string @prop({ required: true, default: PayStatus.PENDING }) public status: PayStatus @@ -52,7 +76,11 @@ class PayRecordClass extends BaseModule { public outOrderId: string // 交易的txHash @prop() - public txHash: string + public txHash?: string + + public static async findByRecordId(this: ReturnModelType, outOrderid: string) { + return this.findOne({ outOrderid }).exec() + } } export const PayRecord = getModelForClass(PayRecordClass, { existingConnection: PayRecordClass.db }) diff --git a/src/modules/TransferRecord.ts b/src/modules/TransferRecord.ts new file mode 100644 index 0000000..d82cbcf --- /dev/null +++ b/src/modules/TransferRecord.ts @@ -0,0 +1,73 @@ +import { ReturnModelType, getModelForClass, index, modelOptions, prop } from '@typegoose/typegoose' +import { dbconn } from 'decorators/dbconn' +import { BaseModule } from './Base' +import { PayType } from './PayRecord' + +/** + * 支付打款记录 + */ +@dbconn() +@index({ recordId: 1 }, { unique: true }) +@modelOptions({ schemaOptions: { collection: 'pay_transfer_record', timestamps: true } }) +export class TransferRecordClass extends BaseModule { + @prop({ required: true }) + public account: string + + // pay_record 的 id + @prop() + public recordId: string + + @prop({ required: true, default: PayType.BUY }) + public type: PayType + + @prop() + public chain: number + + // token 的合约地址 + @prop() + public contract: string + + // 用户的钱包地址 + @prop() + public to: string + + // 转出钱包 + @prop() + public from: string + + @prop() + public amount: string + + @prop() + public gas: string + + @prop() + public gasPrice: string + + /** + * 0: 队列中, 等待上链 + * 1: 已请求上链, 等待结果 + * 2: 成功上链, 等待确认 + * 9: 已确认成功(成功的最终状态) + * 8: 已上报状态 + * 10: 失败 + * 11: 部分失败 + */ + @prop({ default: 0 }) + public status: number + + @prop({ default: 0 }) + public errorCount: number + + @prop({ type: () => [String] }) + public hashList: string[] + + @prop({ default: 0 }) + public version: number + + public static async findByRecordId(this: ReturnModelType, recordId: string) { + return this.findOne({ recordId }).exec() + } +} + +export const TransferRecord = getModelForClass(TransferRecordClass, { existingConnection: TransferRecordClass.db }) diff --git a/src/queue/transfer.queue.ts b/src/queue/transfer.queue.ts new file mode 100644 index 0000000..0cf6830 --- /dev/null +++ b/src/queue/transfer.queue.ts @@ -0,0 +1,82 @@ +import { singleton } from 'decorators/singleton' +import { AsyncQueue, createAsyncQueue } from 'common/AsyncQueue' +import { DocumentType } from '@typegoose/typegoose' +import { PayRecordClass } from 'modules/PayRecord' +import logger from 'logger/logger' +import { pushTaskToChain } from 'service/chain.svr' +import { TransferRecord } from 'modules/TransferRecord' +import config from 'config/config' +import assert from 'assert' + +/** + * let data = { + taskId: '1', + source: 'pay', + cb: 'status update callback url', + data: [ + { + address: '0xb592244aa6477eBDDc14475aaeF921cdDcC0170f', + from: '', + to: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1', + amount: 1000, + type: 3 + }, + ], + } + */ + +@singleton +export class TransferQueue { + private queue: AsyncQueue + + constructor() { + this.queue = createAsyncQueue() + } + + public addTask(task: DocumentType) { + this.queue.push(async () => { + try { + let chainCfg = config.chainCfgs[task.network.toLowerCase()] + assert(chainCfg, `chain config not found: ${task.network}`) + let chainId = chainCfg.chainId + let wallet = chainCfg.wallet + let address = chainCfg.tokens[task.crypto] + assert(address, `token address not found: ${task.crypto}`) + let record = await TransferRecord.insertOrUpdate( + { recordId: task.id }, + { + account: task.account, + chain: chainId, + contract: address, + to: task.address, + from: wallet, + amount: task.cryptoAmount, + $inc: { version: 1 }, + }, + ) + let datas: any = [ + { + chain: record.chain, + address: record.contract, + from: record.from, + to: record.to, + amount: record.amount, + type: 3, + }, + ] + let reqData = { + taskId: record.id, + source: 'pay', + data: datas, + cb: process.env.PAY_TRANSFER_CB_URL, + } + await pushTaskToChain(reqData) + + await task.save() + } catch (err) { + logger.error('error add chain task: ') + logger.error(err) + } + }) + } +} diff --git a/src/service/alchemy.svr.ts b/src/service/alchemy.svr.ts index 589bf4e..e17f74d 100644 --- a/src/service/alchemy.svr.ts +++ b/src/service/alchemy.svr.ts @@ -1,12 +1,13 @@ import axios from 'axios' -import { sha1 } from 'utils/security.util' +import { hmacsha256, sha1 } from 'utils/security.util' import crypto from 'crypto' export function createSimpleSign() { let timestamp = Date.now() let appid = process.env.ALCHEMY_APPID let secret = process.env.ALCHEMY_APP_SECRET - let sign = sha1(appid + secret + timestamp) + // let sign = sha1(appid + secret + timestamp) + let sign = hmacsha256(appid + timestamp, secret) return { appid, timestamp, @@ -21,10 +22,18 @@ export function createSimpleSign() { */ export function checkPayResultSign(data: any) { const { appId, orderNo, crypto, network, address, signature } = data - const sign = sha1(appId + process.env.ALCHEMY_APP_SECRET + appId + orderNo + crypto + network + address) + const sign = hmacsha256(appId + orderNo + crypto + network + address, process.env.ALCHEMY_APP_SECRET) return sign === signature } +export function checkSimpleSign(data: any) { + // alchemy 很不严谨, 有时候是 appid, 有时候是 appId + const { appid, appId, timestamp, sign } = data + let appIdToCheck = appId || appid + const expectedSign = sha1(appIdToCheck + process.env.ALCHEMY_APP_SECRET + timestamp) + return sign === expectedSign +} + /** * Create page sign * @param plainText - plain text to be encrypted @@ -51,6 +60,7 @@ export function createPageSign(plainText: string) { /** * Refresh token + * https://alchemycn.readme.io/docs/获取token * @param email - user email * @returns token */ @@ -101,7 +111,7 @@ export async function createOrder(token: string, data: any) { * https://alchemycn.readme.io/docs/更新订单状态 * TODO:: test */ -export async function updateOrderStatus(token: string, data: any) { +export async function updateOrderStatus(data: any) { const { appid, timestamp, sign } = createSimpleSign() const url = process.env.ALCHEMY_API_BASE + '/webhooks/treasure' const config = { @@ -109,7 +119,6 @@ export async function updateOrderStatus(token: string, data: any) { url, headers: { 'Content-Type': 'application/json', - 'access-token': token, appId: appid, sign: sign, timestamp, @@ -121,6 +130,7 @@ export async function updateOrderStatus(token: string, data: any) { } /** * 查询数字货币币价 + * https://alchemycn.readme.io/docs/查询数字货币币价 */ export async function queryPrice(data: any) { const { appid, timestamp, sign } = createSimpleSign() @@ -147,3 +157,28 @@ export async function queryPrice(data: any) { let response = await axios(config) return response.data } + +/** + * 查询法币及支付方式 + * https://alchemycn.readme.io/docs/查询法币及支付方式 + * @returns + */ +export async function queryFiat() { + const { appid, timestamp, sign } = createSimpleSign() + let dataOrign = { + type: 'BUY', + } + const config = { + method: 'GET', + url: process.env.ALCHEMY_API_BASE + '/merchant/fiat/list', + headers: { + 'Content-Type': 'application/json', + appId: appid, + sign, + timestamp, + }, + data: dataOrign, + } + let response = await axios(config) + return response.data +} diff --git a/src/service/chain.svr.ts b/src/service/chain.svr.ts new file mode 100644 index 0000000..144c914 --- /dev/null +++ b/src/service/chain.svr.ts @@ -0,0 +1,31 @@ +import axios from 'axios' + +const ADD_TASK_URI = '/chain/req' + +const GAS_PRICE_URI = '/chain/estimate_gas' + +export async function pushTaskToChain(data) { + let url = `${process.env.CHAIN_CLIENT_URL}${ADD_TASK_URI}` + let reqConfig: any = { + method: 'post', + url, + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify(data), + } + return axios(reqConfig) +} + +export async function fetchGasPrice(data) { + let url = `${process.env.CHAIN_CLIENT_URL}${GAS_PRICE_URI}` + let reqConfig: any = { + method: 'post', + url, + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify(data), + } + return axios(reqConfig) +} diff --git a/src/utils/security.util.ts b/src/utils/security.util.ts index c99aa42..5d21ccf 100644 --- a/src/utils/security.util.ts +++ b/src/utils/security.util.ts @@ -24,6 +24,13 @@ export function aesDecrypt(encryptedText: string, password: string, iv: string) return decrypted + decipher.final('utf8') } +export function hmacsha256(text: string, secret: string) { + const mac = crypto.createHmac('sha256', secret) + const data = mac.update(text).digest('hex').toLowerCase() + console.log(`HmacSHA256 rawContent is [${text}], key is [${secret}], hash result is [${data}]`) + return data +} + export function sha512(password: string, salt: string) { let hash = crypto.createHmac('sha512', salt) hash.update(password)