diff --git a/index.js b/index.js index abf817d..5f334de 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ -const PriceOracle = require('./oracle/oracle').PriceOracle; -const AuctionBot = require('./auction/auction').AuctionBot; +const PriceOracle = require("./oracle/oracle").PriceOracle; +const AuctionBot = require("./auction/auction").AuctionBot +const RefundBot = require("./refund/refund").RefundBot; module.exports = { PriceOracle, AuctionBot, + RefundBot }; diff --git a/package.json b/package.json index b119fd7..47fbc52 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "homepage": "https://github.com/Kava-Labs/kava-tools#readme", "dependencies": { + "@binance-chain/javascript-sdk": "^3.0.2", "@kava-labs/javascript-sdk": "^2.0.0-beta.6", "axios": "^0.19.2", "coingecko-api": "^1.0.10", diff --git a/refund/example-env b/refund/example-env new file mode 100644 index 0000000..57b2927 --- /dev/null +++ b/refund/example-env @@ -0,0 +1,19 @@ +# Cron tab for how frequently refunds will be attempted e.g. 5 minutes +CRONTAB="5 * * * *" + +# Kava network details +KAVA_LCD_URL="https://kava3.data.kava.io" + +KAVA_MNEMONIC="secret words that unlock your kava address" + +# Binance Chain network details +BINANCE_CHAIN_DEPUTY_ADDRESS = "bnb1jh7uv2rm6339yue8k4mj9406k3509kr4wt5nxn" + +BINANCE_CHAIN_LCD_URL="https://dex.binance.org" + +BINANCE_CHAIN_MNEMONIC="secret words that unlock your binance chain address" + +# These allow the refund bot to be started from the most recent swap batch +BINANCE_CHAIN_START_OFFSET_INCOMING=0 + +BINANCE_CHAIN_START_OFFSET_OUTGOING=0 diff --git a/refund/refund.js b/refund/refund.js new file mode 100644 index 0000000..80a36aa --- /dev/null +++ b/refund/refund.js @@ -0,0 +1,249 @@ +require('dotenv').config() +require('log-timestamp'); +const _ = require('lodash'); +const Kava = require('@kava-labs/javascript-sdk'); +const BnbChain = require("@binance-chain/javascript-sdk"); +const bnbCrypto = BnbChain.crypto; + +/** + * Automatically refunds any refundable swaps on both Kava and Binance Chain + */ +class RefundBot { + constructor(bnbChainDeputy, limit = 1000, offsetIncoming = 0, offsetOutgoing = 0) { + if (!bnbChainDeputy) { + throw new Error("must specify the deputy's Binance Chain address"); + } + this.bnbChainDeputy = bnbChainDeputy + this.limit = limit; + this.offsetIncoming = offsetIncoming; + this.offsetOutgoing = offsetOutgoing; + } + + /** + * Initialize the Kava client + * @param {String} lcdURL api endpoint for Kava's rest-server + * @param {String} mnemonic Kava address mnemonic + * @return {Promise} + */ + async initKavaClient(lcdURL, mnemonic) { + if (!lcdURL) { + throw new Error("Kava's chain's rest-server url is required"); + } + if (!mnemonic) { + throw new Error("Kava address mnemonic is required"); + } + + // Initiate and set Kava client + this.kavaClient = new Kava.KavaClient(lcdURL); + this.kavaClient.setWallet(mnemonic); + try { + await this.kavaClient.initChain(); + } catch (e) { + console.log("Cannot connect to Kava's lcd server:", e) + return + } + return this; + } + + /** + * Initialize the Binance Chain client + * @param {String} lcdURL api endpoint for Binance Chain's rest-server + * @param {String} mnemonic Binance Chain address mnemonic + * @param {String} network "testnet" or "mainnet" + * @return {Promise} + */ + async initBnbChainClient(lcdURL, mnemonic, network = "testnet") { + if (!lcdURL) { + throw new Error("Binance Chain's rest-server url is required"); + } + if (!mnemonic) { + throw new Error("Binance Chain address mnemonic is required"); + } + + // Start Binance Chain client + this.bnbClient = await new BnbChain(lcdURL); + this.bnbClient.chooseNetwork(network); + const privateKey = bnbCrypto.getPrivateKeyFromMnemonic(mnemonic); + this.bnbClient.setPrivateKey(privateKey); + try { + await this.bnbClient.initChain(); + } catch (e) { + console.log("Cannot connect to Binance Chain's lcd server:", e) + return + } + + // Load our Binance Chain address (required for refunds) + const bnbAddrPrefix = network == "mainnet" ? "bnb" : "tbnb" + this.bnbChainAddress = bnbCrypto.getAddressFromPrivateKey(privateKey, bnbAddrPrefix); + + return this; +} + + /** + * Manages swap refunds + */ + async refundSwaps() { + await this.refundKavaSwaps() + await this.refundBinanceChainSwaps() + } + + /** + * Refund any expired swaps on Kava + */ + async refundKavaSwaps() { + const swapIDs = await this.getRefundableKavaSwaps(); + console.log(`Kava refundable swap count: ${swapIDs.length}`) + + // Fetch account data so we can manually manage sequence when posting + let accountData + try { + accountData = await Kava.tx.loadMetaData(this.kavaClient.wallet.address, this.kavaClient.baseURI) + } catch(e) { + console.log(e) + return + } + + // Refund each swap + for(var i = 0; i < swapIDs.length; i++) { + const sequence = String(Number(accountData.sequence) + i) + try { + console.log(`\tRefunding swap ${swapIDs[i]}`) + const txHash = await this.kavaClient.refundSwap(swapIDs[i], sequence) + console.log("\tTx hash:", txHash) + } catch (e) { + console.log(`\tCould not refund swap ${swapIDs[i]}`) + console.log(e) + } + await sleep(7000); // Wait for the block to be confirmed + } + } + + /** + * Gets the swap IDs of all incoming and outgoing expired swaps on Kava + */ + async getRefundableKavaSwaps() { + let expiredSwaps = []; + let checkNextBatch = true; + let page = 1; // After refunding swaps paginated query results will always start from page 1 + + while(checkNextBatch) { + let swapBatch; + const args = {status: 'Expired', page: page, limit: this.limit}; + try { + swapBatch = await this.kavaClient.getSwaps(5000, args); + } catch (e) { + console.log(`couldn't query swaps on Kava...`); + return + } + // If swaps in batch, save them and increment page count + if(swapBatch.length > 0) { + expiredSwaps = expiredSwaps.concat(swapBatch); + page++; + // If no swaps in batch, don't check the next batch + } else { + checkNextBatch = false + } + } + + // Calculate each swap's ID as it's not stored in the struct (it's on the interface) + let swapIDs = [] + for(const expiredSwap of expiredSwaps) { + const swapID = Kava.utils.calculateSwapID( + expiredSwap.random_number_hash, + expiredSwap.sender, + expiredSwap.sender_other_chain, + ) + swapIDs.push(swapID) + } + return swapIDs + } + + /** + * Refund any expired swaps on Binance Chain + */ + async refundBinanceChainSwaps() { + const incomingSwaps = await this.getRefundableBinanceSwaps(true) + const outgoingSwaps = await this.getRefundableBinanceSwaps(false) + const swapIDs = incomingSwaps.concat(outgoingSwaps) + + console.log(`Binance Chain refundable swap count: ${swapIDs.length}`) + + // Refund each swap + for(const swapID of swapIDs) { + console.log(`\tRefunding swap ${swapID}`) + try { + const res = await this.bnbClient.swap.refundHTLT(this.bnbChainAddress, swapID); + if (res && res.status == 200) { + console.log(`\tTx hash: ${res.result[0].hash}`); + } + } + catch(e) { + console.log(`\t${e}`) + } + await sleep(3000); // Wait for the block to be confirmed + } + } + + /** + * Gets the swap IDs of all incoming and outgoing open swaps on Binance Chain + * @param {Boolean} incoming swap direction, defaults to incoming + */ + async getRefundableBinanceSwaps(incoming = true) { + let openSwaps = [] + let checkNextBatch = true + let offsetIncoming = this.offsetIncoming + let offsetOutgoing = this.offsetOutgoing + + while(checkNextBatch) { + let swapBatch + try { + let res + if(incoming) { + res = await this.bnbClient.getSwapByCreator(this.bnbChainDeputy, this.limit, offsetIncoming); + } else { + res = await this.bnbClient.getSwapByRecipient(this.bnbChainDeputy, this.limit, offsetOutgoing); + } + swapBatch = _.get(res, 'result.atomicSwaps'); + + } catch (e) { + console.log(`couldn't query ${incoming ? "incoming" : "outgoing"} swaps on Binance Chain...`) + return + } + + // If swaps in batch, filter for expired swaps + if(swapBatch.length > 0) { + const refundableSwapsInBatch = swapBatch.filter(swap => swap.status == 1) // Status 1 is open + openSwaps = openSwaps.concat(refundableSwapsInBatch) + + // If it's a full batch, increment offset by limit for next iteration + if(swapBatch.length <= this.limit) { + if(incoming) { + offsetIncoming = offsetIncoming + this.limit + } else { + offsetOutgoing = offsetOutgoing + this.limit + } + } + // If no swaps in batch, don't check the next batch + } else { + checkNextBatch = false + } + } + return openSwaps.map(swap => swap.swapId) + } + + /** + * Print the current Binance Chain offsets to console + */ + printOffsets() { + console.log("\nCurrent Binance Chain offsets:") + console.log(`Offset incoming: ${this.offsetIncoming}`) + console.log(`Offset outgoing: ${this.offsetOutgoing}\n`) + } +} + +// Sleep is a wait function +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +module.exports.RefundBot = RefundBot \ No newline at end of file diff --git a/scripts/refund.js b/scripts/refund.js new file mode 100755 index 0000000..dc472e6 --- /dev/null +++ b/scripts/refund.js @@ -0,0 +1,30 @@ +require('dotenv').config({path:'../refund/.env'}) +const RefundBot = require("..").RefundBot +const cron = require('node-cron'); + +var main = async () => { + // Load environment variables + const cronTimer = process.env.CRONTAB; + const kavaLcdURL = process.env.KAVA_LCD_URL; + const kavaMnemonic = process.env.KAVA_MNEMONIC; + const bnbChainLcdURL = process.env.BINANCE_CHAIN_LCD_URL; + const bnbChainMnemonic = process.env.BINANCE_CHAIN_MNEMONIC; + const bnbChainDeputy = process.env.BINANCE_CHAIN_DEPUTY_ADDRESS; + + // Initiate refund bot + refundBot = new RefundBot(bnbChainDeputy); + await refundBot.initKavaClient(kavaLcdURL, kavaMnemonic); + await refundBot.initBnbChainClient(bnbChainLcdURL, bnbChainMnemonic, "mainnet"); + + // Start cron job + cron.schedule(cronTimer, () => { + refundBot.refundSwaps() + }); + + // Print Binance Chain offsets hourly for debugging and future optimization. + cron.schedule("* 1 * * *", () => { + refundBot.printOffsets() + }); +} + +main(); \ No newline at end of file