From 4d288b8dc7228b7179b6fc12ed034cf3603b3642 Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:43 -0500 Subject: [PATCH] fix: cover submitted WBGL transaction errors --- README.md | 153 +++---- service/src/modules/chores.js | 594 +++++++++++++------------ service/src/modules/web3.js | 277 ++++++------ service/src/tests/transactionStatus.js | 36 ++ service/src/utils/config.js | 99 +++-- service/src/utils/transactionStatus.js | 10 + 6 files changed, 627 insertions(+), 542 deletions(-) create mode 100644 service/src/tests/transactionStatus.js create mode 100644 service/src/utils/transactionStatus.js diff --git a/README.md b/README.md index ba625af..783702b 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,80 @@ -# WBGL Bridge - -This is the prototype (proof-of-concept) version of the Bitgesell-WBGL bridge application that allows users to exchange between BGL coins and WBGL ERC-20 tokens. - -Consists of frontend GUI that runs in the browser (React) and communicates with the backend service, which in turn is connected to the Bitgesell network (using RPC of a running node) and an Ethereum gateway (via a websocket endpoint). - -## Setup - -### Backend Service - -Backend service is a Node.js application that exposes a port for HTTP requests. In production, it is recommended to hide it behind an SSL-enabled proxy server (such as nginx). CORS needs to be configured to enable XHR requests from the domain the frontend application is being served from. - -MongoDB database is used for storing data used by the service. - -To set up the service, go to the `service` directory, and run either of the following commands (depending on whether you use npm or yarn as the package manager): -```shell -yarn -``` -or -```shell -npm install -``` - -Backend service is configured using environment variables that need to be set before running the application with Node.js. The following variables are supported: - -- `NODE_ENV`: Application environment. Should be set to `production` on production. -- `PORT`: Port to listen for HTTP requests on. Defaults to `8080`. -- `RPC_HOST`: Hostname or IP address of the Bitgesell node running a JSON-RPC interface. This prototype only supports single-wallet nodes. The custodial wallet should have enough BGL reserve for incoming WBGL exchanges. Defaults to `localhost`. -- `RPC_PORT`: Port the JSON-RPC API is running on. Defaults to `3445`. -- `RPC_USER`: The RPC server user name. -- `RPC_PASSWORD`: The RPC server user password. -- `ETH_ENDPOINT`: The Ethereum API endpoint (currently, only Websocket is supported). For example, an Infura WSS endpoint URL like `wss://mainnet.infura.io/ws/v3/1d17658c92194f73a0143d18fa548a66`. -- `ETH_ACCOUNT`: The Ethereum address of the custodial account WBGL tokens are sent to and from. Should have enough WBGL in reserve for incoming BGL exchanges, as well as enough ether for gas. -- `ETH_PRIVKEY`: The private key for the custodial account as a hexadecimal string. +# WBGL Bridge + +This is the prototype (proof-of-concept) version of the Bitgesell-WBGL bridge application that allows users to exchange between BGL coins and WBGL ERC-20 tokens. + +Consists of frontend GUI that runs in the browser (React) and communicates with the backend service, which in turn is connected to the Bitgesell network (using RPC of a running node) and an Ethereum gateway (via a websocket endpoint). + +## Setup + +### Backend Service + +Backend service is a Node.js application that exposes a port for HTTP requests. In production, it is recommended to hide it behind an SSL-enabled proxy server (such as nginx). CORS needs to be configured to enable XHR requests from the domain the frontend application is being served from. + +MongoDB database is used for storing data used by the service. + +To set up the service, go to the `service` directory, and run either of the following commands (depending on whether you use npm or yarn as the package manager): +```shell +yarn +``` +or +```shell +npm install +``` + +Backend service is configured using environment variables that need to be set before running the application with Node.js. The following variables are supported: + +- `NODE_ENV`: Application environment. Should be set to `production` on production. +- `PORT`: Port to listen for HTTP requests on. Defaults to `8080`. +- `RPC_HOST`: Hostname or IP address of the Bitgesell node running a JSON-RPC interface. This prototype only supports single-wallet nodes. The custodial wallet should have enough BGL reserve for incoming WBGL exchanges. Defaults to `localhost`. +- `RPC_PORT`: Port the JSON-RPC API is running on. Defaults to `3445`. +- `RPC_USER`: The RPC server user name. +- `RPC_PASSWORD`: The RPC server user password. +- `ETH_ENDPOINT`: The Ethereum API endpoint (currently, only Websocket is supported). For example, an Infura WSS endpoint URL like `wss://mainnet.infura.io/ws/v3/1d17658c92194f73a0143d18fa548a66`. +- `ETH_ACCOUNT`: The Ethereum address of the custodial account WBGL tokens are sent to and from. Should have enough WBGL in reserve for incoming BGL exchanges, as well as enough ether for gas. +- `ETH_PRIVKEY`: The private key for the custodial account as a hexadecimal string. - `ETH_CONTRACT_ADDRESS`: The ethereum address of the WBGL ERC-20 token contract. - `DB_CONNECTION`: MongoDB connection string in the following format: `mongodb://username:password@hostname:27017`. - `DB_DATABASE`: Name of the MongoDB database. Defaults to `wbgl_bridge`. - -The command for running the service is: -```shell -node src/server.js -``` - -### Frontend Application - -Frontend application is a minimal GUI developed in React. To prepare the application build process, go to the `app` folder, and install the dependencies using one of the following commands: -```shell -yarn -``` -or -```shell -npm install -``` - -Before building the application, you'll need to set the `REACT_APP_SERVICE_URL` environment variable, which should contain the URL the background service is running at. It should look something like this: `https://service.domain`. - -To build the application, run one of the following commands: -```shell -yarn build -``` -or -```shell -npm run build -``` - -This will create the static application bundle in the `build` subdirectory, which can be directly served using any web server. - -## Caveats - -As mentioned above, this is a proof-of-concept implementation of the Bitgesell-WBGL bridge application. As such, it comes with multiple shortcomings that should be overcome before launching a full scale service. Some of these shortcomings are: - -- Very minimal GUI. Should add a lot of features (trade details, status updates, dedicated trade url, etc). -- No DDoS protection. Should implement at least CAPTCHA. -- Limited error checking. All possible exception paths should be covered. -- No set limits for transfer amounts. If custodial wallet has insufficient funds to fulfil a trade, transfers will simply fail. -- No fee calculations. Transaction fees should be covered by the user instead of the service. -- No wallet integration. Should support at least address autofill and message signing. -- Not a typed language. Should be rewritten in e.g. Typescript. -- No unit tests. +- `PENDING_TX_MAX_CHECKS`: Number of one-minute pending transaction checks before a missing submitted WBGL transaction is marked as errored. Defaults to `180`; set to `0` to disable this expiry. + +The command for running the service is: +```shell +node src/server.js +``` + +### Frontend Application + +Frontend application is a minimal GUI developed in React. To prepare the application build process, go to the `app` folder, and install the dependencies using one of the following commands: +```shell +yarn +``` +or +```shell +npm install +``` + +Before building the application, you'll need to set the `REACT_APP_SERVICE_URL` environment variable, which should contain the URL the background service is running at. It should look something like this: `https://service.domain`. + +To build the application, run one of the following commands: +```shell +yarn build +``` +or +```shell +npm run build +``` + +This will create the static application bundle in the `build` subdirectory, which can be directly served using any web server. + +## Caveats + +As mentioned above, this is a proof-of-concept implementation of the Bitgesell-WBGL bridge application. As such, it comes with multiple shortcomings that should be overcome before launching a full scale service. Some of these shortcomings are: + +- Very minimal GUI. Should add a lot of features (trade details, status updates, dedicated trade url, etc). +- No DDoS protection. Should implement at least CAPTCHA. +- Limited error checking. All possible exception paths should be covered. +- No set limits for transfer amounts. If custodial wallet has insufficient funds to fulfil a trade, transfers will simply fail. +- No fee calculations. Transaction fees should be covered by the user instead of the service. +- No wallet integration. Should support at least address autofill and message signing. +- Not a typed language. Should be rewritten in e.g. Typescript. +- No unit tests. diff --git a/service/src/modules/chores.js b/service/src/modules/chores.js index 30de7d9..416250a 100644 --- a/service/src/modules/chores.js +++ b/service/src/modules/chores.js @@ -1,297 +1,327 @@ -import { confirmations, feePercentage, bsc, nonces } from "../utils/config.js"; +import { + confirmations, + feePercentage, + bsc, + nonces, + pendingTxMaxChecks, +} from "../utils/config.js"; import { Data, RPC, Eth, Bsc } from "./index.js"; import Transaction from "../models/Transaction.js"; import Transfer from "../models/Transfer.js"; import Conversion from "../models/Conversion.js"; - -let ethNonce = 0; -let bscNonce = 0; - -function setupNonce(){ - if ((ethNonce == 0) && (nonces.eth == 0)) { - ethNonce = Eth.getTransactionCount(); - } else if ((nonces.eth > 0) && (nonces.eth > ethNonce)) { - ethNonce = nonces.eth; - } - if ((bscNonce == 0) && (nonces.bsc == 0)) { - bscNonce = Bsc.getTransactionCount(); - } else if ((nonces.bsc > 0) && (nonces.bsc > bscNonce)) { - bscNonce = nonces.bsc; - } -} - -const expireDate = () => { - const expireDate = new Date(); - expireDate.setTime(expireDate.getTime() - 7 * 24 * 3600000); - return expireDate.toISOString(); -}; - -const deductFee = (amount) => - parseFloat((((100 - feePercentage) * amount) / 100).toFixed(3)); - -async function returnBGL(conversion, address) { - try { - conversion.status = "returned"; - await conversion.save(); - conversion.returnTxid = await RPC.send(address, conversion.amount); - await conversion.save(); - } catch (e) { - console.error( - `Error returning BGL to ${address}, conversion ID: ${conversion._id}.`, - e, - ); - conversion.status = "error"; - await conversion.save(); - } -} - -async function returnWBGL(Chain, conversion, address) { - try { - conversion.status = "returned"; - await conversion.save(); - conversion.returnTxid = await Chain.sendWBGL( - address, - conversion.amount.toString(), - ); - await conversion.save(); - } catch (e) { - console.error( - `Error returning WBGL (${Chain.id}) to ${address}, conversion ID: ${conversion._id}.`, - e, - ); - conversion.status = "error"; - await conversion.save(); - } -} - -async function checkBglTransactions() { - try { - const blockHash = await Data.get("lastBglBlockHash"); - const result = await RPC.listSinceBlock( - blockHash || undefined, - confirmations.bgl, - ); - setupNonce(); - result.transactions - .filter( - (tx) => - tx.confirmations >= confirmations.bgl && tx.category === "receive", - ) - .forEach((tx) => { - Transfer.findOne({ - type: "bgl", - from: tx.address, - updatedAt: { $gte: expireDate() }, - }) - .exec() - .then(async (transfer) => { - if ( - transfer && - !(await Transaction.findOne({ id: tx["txid"] }).exec()) - ) { - const Chain = transfer.chain === "bsc" ? Bsc : Eth; - const fromAddress = await RPC.getTransactionFromAddress( - tx["txid"], - ); - const transaction = await Transaction.create({ - type: "bgl", - id: tx["txid"], - transfer: transfer._id, - address: fromAddress, - amount: tx["amount"], - blockHash: tx["blockhash"], - time: new Date(tx["time"] * 1000), - }); - const amount = deductFee(tx["amount"]); - const conversion = await Conversion.create({ - type: "wbgl", - chain: transfer.chain, - transfer: transfer._id, - transaction: transaction._id, - address: transfer.to, - amount: tx["amount"], - sendAmount: amount, - }); - - if (amount > (await Chain.getWBGLBalance())) { - console.log( - `Insufficient WBGL balance, returning ${tx["amount"]} BGL to ${fromAddress}`, - ); - await returnBGL(conversion, fromAddress); - return; - } - - try { - const AssignedNonce = transfer.chain === "bsc" ? Bsc : Eth; - if (transfer.chain === "bsc") { - bscNonce += 1; - conversion.txid = await Chain.sendWBGL( - transfer.to, - amount.toString(), - bscNonce - ); - } else { - ethNonce += 1; - conversion.txid = await Chain.sendWBGL( - transfer.to, - amount.toString(), - ethNonce - ); - } - await conversion.save(); - } catch (e) { - console.log( - `Error sending ${amount} WBGL to ${transfer.to}`, - e, - ); - conversion.status = "error"; - await conversion.save(); - - await returnBGL(conversion, fromAddress); - } - } - }); - }); - - await Data.set("lastBglBlockHash", result["lastblock"]); - } catch (e) { - console.error("Error: checkBglTransactions function failed. Check network"); - } - - setTimeout(checkBglTransactions, 60000); -} - -export async function checkWbglTransfers(Chain = Eth, prefix = "Eth") { - try { - const currentBlock = await Chain.web3.eth.getBlockNumber(); - console.log("currentBlock: ", currentBlock); - const blockNumber = Math.max( - await Data.get(`last${prefix}BlockNumber`, currentBlock - 2000), - currentBlock - 2000, - ); - console.log("blockNumber: ", blockNumber); - const events = await Chain.WBGL.getPastEvents("Transfer", { - fromBlock: blockNumber + 1, - toBlock: currentBlock, - filter: { to: Chain.custodialAccountAddress }, - }); - console.log("event : ", events); - events.forEach((event) => { - const fromQuery = { - $regex: new RegExp(`^${event.returnValues.from}$`, "i"), - }; - console.log("fromQuery:", fromQuery); - Transfer.findOne({ - type: "wbgl", - chain: Chain.id, - from: fromQuery, - updatedAt: { $gte: expireDate() }, - }) - .exec() - .then(async (transfer) => { - if ( - transfer && - !(await Transaction.findOne({ - chain: Chain.id, - id: event.transactionHash, - }).exec()) - ) { - const amount = Chain.convertWGBLBalance(event.returnValues.value); - const sendAmount = deductFee(amount); - const transaction = await Transaction.create({ - type: "wbgl", - chain: Chain.id, - id: event.transactionHash, - transfer: transfer._id, - address: event.returnValues.from, - amount, - blockHash: event.blockHash, - time: Date.now(), - }); - console.log("transaction:", transaction); - const conversion = await Conversion.create({ - type: "bgl", - chain: Chain.id, - transfer: transfer._id, - transaction: transaction._id, - address: transfer.to, - amount, - sendAmount, - }); - console.log("amount : ", amount); - if (amount > (await RPC.getBalance())) { - console.log( - `Insufficient BGL balance, returning ${amount} WBGL to ${transfer.from}`, - ); - await returnWBGL(Chain, conversion, transfer.from); - return; - } - - try { - conversion.txid = await RPC.send(transfer.to, sendAmount); - conversion.status = "sent"; - await conversion.save(); - } catch (e) { - console.error( - `Error sending ${sendAmount} BGL to ${transfer.to}`, - e, - ); - conversion.status = "error"; - await conversion.save(); - - await returnWBGL(Chain, conversion, transfer.from); - } - } - }); - Data.set(`last${prefix}BlockHash`, event.blockHash); - Data.set(`last${prefix}BlockNumber`, event.blockNumber); - }); - } catch (e) { - console.error("Error: checkWbglTransfers function failed. Check network"); - } - - setTimeout(() => checkWbglTransfers(Chain, prefix), 60000); -} - -async function checkPendingConversions(Chain) { - const conversions = await Conversion.find({ - chain: Chain.id, - type: "wbgl", - status: "pending", - txid: { $exists: true }, - }).exec(); - let blockNumber; - console.log("checkPendingConversions:", conversions); - try { - for (const conversion of conversions) { +import { + isFailedReceipt, + shouldExpirePendingReceipt, +} from "../utils/transactionStatus.js"; + +let ethNonce = 0; +let bscNonce = 0; + +function setupNonce(){ + if ((ethNonce == 0) && (nonces.eth == 0)) { + ethNonce = Eth.getTransactionCount(); + } else if ((nonces.eth > 0) && (nonces.eth > ethNonce)) { + ethNonce = nonces.eth; + } + if ((bscNonce == 0) && (nonces.bsc == 0)) { + bscNonce = Bsc.getTransactionCount(); + } else if ((nonces.bsc > 0) && (nonces.bsc > bscNonce)) { + bscNonce = nonces.bsc; + } +} + +const expireDate = () => { + const expireDate = new Date(); + expireDate.setTime(expireDate.getTime() - 7 * 24 * 3600000); + return expireDate.toISOString(); +}; + +const deductFee = (amount) => + parseFloat((((100 - feePercentage) * amount) / 100).toFixed(3)); + +async function returnBGL(conversion, address) { + try { + conversion.status = "returned"; + await conversion.save(); + conversion.returnTxid = await RPC.send(address, conversion.amount); + await conversion.save(); + } catch (e) { + console.error( + `Error returning BGL to ${address}, conversion ID: ${conversion._id}.`, + e, + ); + conversion.status = "error"; + await conversion.save(); + } +} + +async function returnWBGL(Chain, conversion, address) { + try { + conversion.status = "returned"; + await conversion.save(); + conversion.returnTxid = await Chain.sendWBGL( + address, + conversion.amount.toString(), + ); + await conversion.save(); + } catch (e) { + console.error( + `Error returning WBGL (${Chain.id}) to ${address}, conversion ID: ${conversion._id}.`, + e, + ); + conversion.status = "error"; + await conversion.save(); + } +} + +async function checkBglTransactions() { + try { + const blockHash = await Data.get("lastBglBlockHash"); + const result = await RPC.listSinceBlock( + blockHash || undefined, + confirmations.bgl, + ); + setupNonce(); + result.transactions + .filter( + (tx) => + tx.confirmations >= confirmations.bgl && tx.category === "receive", + ) + .forEach((tx) => { + Transfer.findOne({ + type: "bgl", + from: tx.address, + updatedAt: { $gte: expireDate() }, + }) + .exec() + .then(async (transfer) => { + if ( + transfer && + !(await Transaction.findOne({ id: tx["txid"] }).exec()) + ) { + const Chain = transfer.chain === "bsc" ? Bsc : Eth; + const fromAddress = await RPC.getTransactionFromAddress( + tx["txid"], + ); + const transaction = await Transaction.create({ + type: "bgl", + id: tx["txid"], + transfer: transfer._id, + address: fromAddress, + amount: tx["amount"], + blockHash: tx["blockhash"], + time: new Date(tx["time"] * 1000), + }); + const amount = deductFee(tx["amount"]); + const conversion = await Conversion.create({ + type: "wbgl", + chain: transfer.chain, + transfer: transfer._id, + transaction: transaction._id, + address: transfer.to, + amount: tx["amount"], + sendAmount: amount, + }); + + if (amount > (await Chain.getWBGLBalance())) { + console.log( + `Insufficient WBGL balance, returning ${tx["amount"]} BGL to ${fromAddress}`, + ); + await returnBGL(conversion, fromAddress); + return; + } + + try { + const AssignedNonce = transfer.chain === "bsc" ? Bsc : Eth; + if (transfer.chain === "bsc") { + bscNonce += 1; + conversion.txid = await Chain.sendWBGL( + transfer.to, + amount.toString(), + bscNonce + ); + } else { + ethNonce += 1; + conversion.txid = await Chain.sendWBGL( + transfer.to, + amount.toString(), + ethNonce + ); + } + await conversion.save(); + } catch (e) { + console.log( + `Error sending ${amount} WBGL to ${transfer.to}`, + e, + ); + conversion.status = "error"; + await conversion.save(); + + await returnBGL(conversion, fromAddress); + } + } + }); + }); + + await Data.set("lastBglBlockHash", result["lastblock"]); + } catch (e) { + console.error("Error: checkBglTransactions function failed. Check network"); + } + + setTimeout(checkBglTransactions, 60000); +} + +export async function checkWbglTransfers(Chain = Eth, prefix = "Eth") { + try { + const currentBlock = await Chain.web3.eth.getBlockNumber(); + console.log("currentBlock: ", currentBlock); + const blockNumber = Math.max( + await Data.get(`last${prefix}BlockNumber`, currentBlock - 2000), + currentBlock - 2000, + ); + console.log("blockNumber: ", blockNumber); + const events = await Chain.WBGL.getPastEvents("Transfer", { + fromBlock: blockNumber + 1, + toBlock: currentBlock, + filter: { to: Chain.custodialAccountAddress }, + }); + console.log("event : ", events); + events.forEach((event) => { + const fromQuery = { + $regex: new RegExp(`^${event.returnValues.from}$`, "i"), + }; + console.log("fromQuery:", fromQuery); + Transfer.findOne({ + type: "wbgl", + chain: Chain.id, + from: fromQuery, + updatedAt: { $gte: expireDate() }, + }) + .exec() + .then(async (transfer) => { + if ( + transfer && + !(await Transaction.findOne({ + chain: Chain.id, + id: event.transactionHash, + }).exec()) + ) { + const amount = Chain.convertWGBLBalance(event.returnValues.value); + const sendAmount = deductFee(amount); + const transaction = await Transaction.create({ + type: "wbgl", + chain: Chain.id, + id: event.transactionHash, + transfer: transfer._id, + address: event.returnValues.from, + amount, + blockHash: event.blockHash, + time: Date.now(), + }); + console.log("transaction:", transaction); + const conversion = await Conversion.create({ + type: "bgl", + chain: Chain.id, + transfer: transfer._id, + transaction: transaction._id, + address: transfer.to, + amount, + sendAmount, + }); + console.log("amount : ", amount); + if (amount > (await RPC.getBalance())) { + console.log( + `Insufficient BGL balance, returning ${amount} WBGL to ${transfer.from}`, + ); + await returnWBGL(Chain, conversion, transfer.from); + return; + } + + try { + conversion.txid = await RPC.send(transfer.to, sendAmount); + conversion.status = "sent"; + await conversion.save(); + } catch (e) { + console.error( + `Error sending ${sendAmount} BGL to ${transfer.to}`, + e, + ); + conversion.status = "error"; + await conversion.save(); + + await returnWBGL(Chain, conversion, transfer.from); + } + } + }); + Data.set(`last${prefix}BlockHash`, event.blockHash); + Data.set(`last${prefix}BlockNumber`, event.blockNumber); + }); + } catch (e) { + console.error("Error: checkWbglTransfers function failed. Check network"); + } + + setTimeout(() => checkWbglTransfers(Chain, prefix), 60000); +} + +async function checkPendingConversions(Chain) { + const conversions = await Conversion.find({ + chain: Chain.id, + type: "wbgl", + status: "pending", + txid: { $exists: true }, + }).exec(); + let blockNumber; + console.log("checkPendingConversions:", conversions); + try { + for (const conversion of conversions) { const receipt = await Chain.getTransactionReceipt(conversion.txid); console.log("receipt", receipt); blockNumber = blockNumber || (await Chain.web3.eth.getBlockNumber()); - if (receipt && blockNumber - receipt.blockNumber >= Chain.confirmations) { + if (isFailedReceipt(receipt)) { + conversion.status = "error"; + conversion.receipt = receipt; + conversion.markModified("receipt"); + console.error( + `WBGL transaction ${conversion.txid} failed on ${Chain.id}, conversion ID: ${conversion._id}.`, + ); + } else if ( + receipt && + blockNumber - receipt.blockNumber >= Chain.confirmations + ) { conversion.status = "sent"; conversion.receipt = receipt; conversion.markModified("receipt"); } else { - conversion.txChecks = (conversion.txChecks || 0) + 1; + const txChecks = (conversion.txChecks || 0) + 1; + conversion.txChecks = txChecks; + if ( + !receipt && + shouldExpirePendingReceipt(txChecks, pendingTxMaxChecks) + ) { + conversion.status = "error"; + console.error( + `WBGL transaction ${conversion.txid} was not found after ${txChecks} checks, conversion ID: ${conversion._id}.`, + ); + } } await conversion.save(); - } - } catch (e) { - console.error( - "Error: checkPendingConversions function failed. Check chain network or mongodb", - ); - } - setTimeout(() => checkPendingConversions(Chain), 60000); -} - -export const init = async () => { - await checkWbglTransfers(Eth, "Eth"); - await checkWbglTransfers(Bsc, "Bsc"); - - await checkBglTransactions(); - - await checkPendingConversions(Eth); - await checkPendingConversions(Bsc); -}; + } + } catch (e) { + console.error( + "Error: checkPendingConversions function failed. Check chain network or mongodb", + ); + } + setTimeout(() => checkPendingConversions(Chain), 60000); +} + +export const init = async () => { + await checkWbglTransfers(Eth, "Eth"); + await checkWbglTransfers(Bsc, "Bsc"); + + await checkBglTransactions(); + + await checkPendingConversions(Eth); + await checkPendingConversions(Bsc); +}; diff --git a/service/src/modules/web3.js b/service/src/modules/web3.js index f91bd8a..264c167 100644 --- a/service/src/modules/web3.js +++ b/service/src/modules/web3.js @@ -1,142 +1,145 @@ -import Web3 from "web3"; -//import {Transaction} from 'ethereumjs-tx' -import pkg from "ethereumjs-tx"; -const { Transaction } = pkg; -import fs from "fs"; -import { Data } from "../modules/index.js"; -import { toBaseUnit } from "../utils/index.js"; - -const bn = Web3.utils.toBN; -const createProvider = (endpoint) => new Web3.providers.HttpProvider(endpoint); - -class Web3Base { - decimals = 18; - - constructor( - endpoint, - id, - contractAddress, - custodialAccountAddress, - custodialAccountKey, - nonceDataName, - confirmations, - ) { - this.id = id; - this.contractAddress = contractAddress; - this.custodialAccountAddress = custodialAccountAddress; - this.custodialAccountKey = custodialAccountKey; - this.nonceDataName = nonceDataName; - this.confirmations = confirmations; - - this.web3 = new Web3(createProvider(endpoint)); - this.WBGL = new this.web3.eth.Contract( - JSON.parse(fs.readFileSync("abi/WBGL.json", "utf8")), - contractAddress, - ); - this.WBGL.methods["decimals"]() - .call() - .then((decimals) => (this.decimals = decimals)); - - this.init() - .then(() => {}) - .catch((err) => console.log("error ", err)); - } - - async init() { - const chain = await this.web3.eth.net.getNetworkType(); - this.chain = ["main", "private"].includes(chain) ? "mainnet" : chain; - } - - getChain() { - return this.chain; - } - - async getGasPrice() { - const gasPrice = await this.web3.eth.getGasPrice(); - return this.web3.utils.fromWei(gasPrice, "Gwei"); - } - - async getEstimateGas(amount) { - return await this.WBGL.methods["transfer"]( - this.custodialAccountAddress, - toBaseUnit(amount, this.decimals), - ).estimateGas({ from: this.custodialAccountAddress }); - } - - async getWBGLBalance() { - return this.convertWGBLBalance( - await this.WBGL.methods["balanceOf"](this.custodialAccountAddress).call(), - ); - } - - async getWBGLBalance1(address) { - return this.convertWGBLBalance( - await this.WBGL.methods["balanceOf"](address).call(), - ); - } - - convertWGBLBalance(number, resultDecimals = this.decimals) { - const balance = bn(number); - const divisor = bn(10).pow(bn(this.decimals)); - const beforeDec = balance.div(divisor).toString(); - const afterDec = balance - .mod(divisor) - .toString() - .padStart(this.decimals, "0") - .substring(0, resultDecimals); - return beforeDec + (afterDec !== "0" ? "." + afterDec : ""); - } - - async getTransactionCount() { - return await this.web3.eth.getTransactionCount( - this.custodialAccountAddress, - "latest", - ); - } - - async getTransactionReceipt(txid) { - return await this.web3.eth.getTransactionReceipt(txid); - } - - sendWBGL(address, amount, nonce) { - return new Promise(async (resolve, reject) => { - const data = this.WBGL.methods["transfer"]( - address, - toBaseUnit(amount, this.decimals), - ).encodeABI(); - const rawTx = { - nonce: this.web3.utils.toHex(nonce), - gasPrice: this.web3.utils.toHex( - this.web3.utils.toWei( - Math.ceil(1.25 * parseFloat(await this.getGasPrice())).toString(), - "Gwei", - ), - ), - gasLimit: this.web3.utils.toHex( - (await this.getEstimateGas(amount)) * 2, - ), - from: this.custodialAccountAddress, - to: this.contractAddress, - value: "0x00", - data, - }; - console.log(rawTx); - - const tx = new Transaction(rawTx, await this.transactionOpts()); - tx.sign(this.custodialAccountKey); - - const serializedTx = "0x" + tx.serialize().toString("hex"); - console.log("Serialized tx: " + serializedTx); +import Web3 from "web3"; +//import {Transaction} from 'ethereumjs-tx' +import pkg from "ethereumjs-tx"; +const { Transaction } = pkg; +import fs from "fs"; +import { Data } from "../modules/index.js"; +import { toBaseUnit } from "../utils/index.js"; + +const bn = Web3.utils.toBN; +const createProvider = (endpoint) => new Web3.providers.HttpProvider(endpoint); + +class Web3Base { + decimals = 18; + + constructor( + endpoint, + id, + contractAddress, + custodialAccountAddress, + custodialAccountKey, + nonceDataName, + confirmations, + ) { + this.id = id; + this.contractAddress = contractAddress; + this.custodialAccountAddress = custodialAccountAddress; + this.custodialAccountKey = custodialAccountKey; + this.nonceDataName = nonceDataName; + this.confirmations = confirmations; + + this.web3 = new Web3(createProvider(endpoint)); + this.WBGL = new this.web3.eth.Contract( + JSON.parse(fs.readFileSync("abi/WBGL.json", "utf8")), + contractAddress, + ); + this.WBGL.methods["decimals"]() + .call() + .then((decimals) => (this.decimals = decimals)); + + this.init() + .then(() => {}) + .catch((err) => console.log("error ", err)); + } + + async init() { + const chain = await this.web3.eth.net.getNetworkType(); + this.chain = ["main", "private"].includes(chain) ? "mainnet" : chain; + } + + getChain() { + return this.chain; + } + + async getGasPrice() { + const gasPrice = await this.web3.eth.getGasPrice(); + return this.web3.utils.fromWei(gasPrice, "Gwei"); + } + + async getEstimateGas(amount) { + return await this.WBGL.methods["transfer"]( + this.custodialAccountAddress, + toBaseUnit(amount, this.decimals), + ).estimateGas({ from: this.custodialAccountAddress }); + } + + async getWBGLBalance() { + return this.convertWGBLBalance( + await this.WBGL.methods["balanceOf"](this.custodialAccountAddress).call(), + ); + } + + async getWBGLBalance1(address) { + return this.convertWGBLBalance( + await this.WBGL.methods["balanceOf"](address).call(), + ); + } + + convertWGBLBalance(number, resultDecimals = this.decimals) { + const balance = bn(number); + const divisor = bn(10).pow(bn(this.decimals)); + const beforeDec = balance.div(divisor).toString(); + const afterDec = balance + .mod(divisor) + .toString() + .padStart(this.decimals, "0") + .substring(0, resultDecimals); + return beforeDec + (afterDec !== "0" ? "." + afterDec : ""); + } + + async getTransactionCount() { + return await this.web3.eth.getTransactionCount( + this.custodialAccountAddress, + "latest", + ); + } + + async getTransactionReceipt(txid) { + return await this.web3.eth.getTransactionReceipt(txid); + } + + sendWBGL(address, amount, nonce) { + return new Promise(async (resolve, reject) => { + const data = this.WBGL.methods["transfer"]( + address, + toBaseUnit(amount, this.decimals), + ).encodeABI(); + const rawTx = { + nonce: this.web3.utils.toHex(nonce), + gasPrice: this.web3.utils.toHex( + this.web3.utils.toWei( + Math.ceil(1.25 * parseFloat(await this.getGasPrice())).toString(), + "Gwei", + ), + ), + gasLimit: this.web3.utils.toHex( + (await this.getEstimateGas(amount)) * 2, + ), + from: this.custodialAccountAddress, + to: this.contractAddress, + value: "0x00", + data, + }; + console.log(rawTx); + + const tx = new Transaction(rawTx, await this.transactionOpts()); + tx.sign(this.custodialAccountKey); + + const serializedTx = "0x" + tx.serialize().toString("hex"); + console.log("Serialized tx: " + serializedTx); this.web3.eth .sendSignedTransaction(serializedTx) .on("transactionHash", resolve) - .on('error', console.error); + .on("error", (error) => { + console.error("Error sending signed WBGL transaction", error); + reject(error); + }); }); } - - async transactionOpts() { - return { chain: this.chain }; - } -} - -export default Web3Base; + + async transactionOpts() { + return { chain: this.chain }; + } +} + +export default Web3Base; diff --git a/service/src/tests/transactionStatus.js b/service/src/tests/transactionStatus.js new file mode 100644 index 0000000..6df1e23 --- /dev/null +++ b/service/src/tests/transactionStatus.js @@ -0,0 +1,36 @@ +import assert from "assert"; +import { + isFailedReceipt, + shouldExpirePendingReceipt, +} from "../utils/transactionStatus.js"; + +describe("submitted transaction status helpers", function () { + describe("#isFailedReceipt", function () { + it("detects failed receipt status shapes", function () { + assert.equal(isFailedReceipt({ status: false }), true); + assert.equal(isFailedReceipt({ status: 0 }), true); + assert.equal(isFailedReceipt({ status: "0" }), true); + assert.equal(isFailedReceipt({ status: "0x0" }), true); + }); + + it("ignores successful or missing receipt status", function () { + assert.equal(isFailedReceipt({ status: true }), false); + assert.equal(isFailedReceipt({ status: 1 }), false); + assert.equal(isFailedReceipt({}), false); + assert.equal(isFailedReceipt(null), false); + }); + }); + + describe("#shouldExpirePendingReceipt", function () { + it("expires only when the configured check limit is reached", function () { + assert.equal(shouldExpirePendingReceipt(179, 180), false); + assert.equal(shouldExpirePendingReceipt(180, 180), true); + assert.equal(shouldExpirePendingReceipt(181, 180), true); + }); + + it("keeps pending transactions open when expiry is disabled", function () { + assert.equal(shouldExpirePendingReceipt(180, 0), false); + assert.equal(shouldExpirePendingReceipt(180, -1), false); + }); + }); +}); diff --git a/service/src/utils/config.js b/service/src/utils/config.js index 33f8f94..972a0c9 100644 --- a/service/src/utils/config.js +++ b/service/src/utils/config.js @@ -1,52 +1,57 @@ -import dotenv from 'dotenv'; -dotenv.config(); - -export const env = process.env.NODE_ENV || "development"; - -export const port = process.env.PORT || "8080"; - -const rpcConfig = { - host: process.env.RPC_HOST || "localhost", - port: process.env.RPC_PORT || "8332", -}; -if (process.env.hasOwnProperty("RPC_USER") && process.env.RPC_USER) { - rpcConfig.username = process.env.RPC_USER; -} -if (process.env.hasOwnProperty("RPC_PASSWORD") && process.env.RPC_PASSWORD) { - rpcConfig.password = process.env.RPC_PASSWORD; -} -if (process.env.hasOwnProperty("RPC_WALLET") && process.env.RPC_WALLET) { - rpcConfig.wallet = process.env.RPC_WALLET; -} -export const rpc = rpcConfig; - -export const eth = { - endpoint: process.env.ETH_ENDPOINT, - account: process.env.ETH_ACCOUNT, - key: Buffer.from(process.env.ETH_PRIVKEY, "hex"), - contract: process.env.ETH_CONTRACT_ADDRESS, -}; - -export const bsc = { - endpoint: process.env.BSC_ENDPOINT, - account: process.env.BSC_ACCOUNT, - key: Buffer.from(process.env.BSC_PRIVKEY, "hex"), - contract: process.env.BSC_CONTRACT_ADDRESS, -}; - -export const mongo = { - url: process.env.DB_CONNECTION, - database: process.env.DB_DATABASE || "wbgl_bridge", -}; - -export const confirmations = { - bgl: parseInt(process.env.BGL_MIN_CONFIRMATIONS) || 3, - eth: parseInt(process.env.ETH_MIN_CONFIRMATIONS) || 3, - bsc: parseInt(process.env.BSC_MIN_CONFIRMATIONS) || 3, -}; - +import dotenv from 'dotenv'; +dotenv.config(); + +export const env = process.env.NODE_ENV || "development"; + +export const port = process.env.PORT || "8080"; + +const rpcConfig = { + host: process.env.RPC_HOST || "localhost", + port: process.env.RPC_PORT || "8332", +}; +if (process.env.hasOwnProperty("RPC_USER") && process.env.RPC_USER) { + rpcConfig.username = process.env.RPC_USER; +} +if (process.env.hasOwnProperty("RPC_PASSWORD") && process.env.RPC_PASSWORD) { + rpcConfig.password = process.env.RPC_PASSWORD; +} +if (process.env.hasOwnProperty("RPC_WALLET") && process.env.RPC_WALLET) { + rpcConfig.wallet = process.env.RPC_WALLET; +} +export const rpc = rpcConfig; + +export const eth = { + endpoint: process.env.ETH_ENDPOINT, + account: process.env.ETH_ACCOUNT, + key: Buffer.from(process.env.ETH_PRIVKEY, "hex"), + contract: process.env.ETH_CONTRACT_ADDRESS, +}; + +export const bsc = { + endpoint: process.env.BSC_ENDPOINT, + account: process.env.BSC_ACCOUNT, + key: Buffer.from(process.env.BSC_PRIVKEY, "hex"), + contract: process.env.BSC_CONTRACT_ADDRESS, +}; + +export const mongo = { + url: process.env.DB_CONNECTION, + database: process.env.DB_DATABASE || "wbgl_bridge", +}; + +export const confirmations = { + bgl: parseInt(process.env.BGL_MIN_CONFIRMATIONS) || 3, + eth: parseInt(process.env.ETH_MIN_CONFIRMATIONS) || 3, + bsc: parseInt(process.env.BSC_MIN_CONFIRMATIONS) || 3, +}; + export const feePercentage = process.env.FEE_PERCENTAGE || 1; +const pendingTxMaxChecksValue = parseInt(process.env.PENDING_TX_MAX_CHECKS); +export const pendingTxMaxChecks = Number.isFinite(pendingTxMaxChecksValue) + ? pendingTxMaxChecksValue + : 180; + export const nonces = { bsc: parseInt(process.env.BSC_NONCE) || 0, eth: parseInt(process.env.ETH__NONCE) || 0, diff --git a/service/src/utils/transactionStatus.js b/service/src/utils/transactionStatus.js new file mode 100644 index 0000000..e3967b4 --- /dev/null +++ b/service/src/utils/transactionStatus.js @@ -0,0 +1,10 @@ +export const isFailedReceipt = (receipt) => { + if (!receipt || !Object.prototype.hasOwnProperty.call(receipt, "status")) { + return false; + } + + return [false, 0, 0n, "0", "0x0"].includes(receipt.status); +}; + +export const shouldExpirePendingReceipt = (txChecks, maxChecks) => + Number.isFinite(maxChecks) && maxChecks > 0 && txChecks >= maxChecks;