├── .gitattributes ├── comment.md ├── blacklist ├── package.json ├── LICENSE ├── .gitignore ├── delegators.js ├── config-example.json ├── utils.js ├── README.md └── postpromoter.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto -------------------------------------------------------------------------------- /comment.md: -------------------------------------------------------------------------------- 1 | You got a {weight}% upvote from @{botname} courtesy of @{sender}! 2 | -------------------------------------------------------------------------------- /blacklist: -------------------------------------------------------------------------------- 1 | blacklisted_account_1 2 | blacklisted_account_2 3 | blacklisted_account_3 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postpromoter", 3 | "version": "2.1.1", 4 | "description": "Steem bid-based voting bot written in JavaScript", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/MattyIce/postpromoter.git" 12 | }, 13 | "author": "yabapmatt", 14 | "license": "MIT", 15 | "dependencies": { 16 | "dsteem": "^0.10.1", 17 | "express": "^4.16.3", 18 | "request": "^2.88.0", 19 | "steem": "^0.7.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Post Promoter, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # config 2 | config.json 3 | state.json 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules 41 | jspm_packages 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # other 65 | .sublime-project 66 | delegators.json 67 | whitelist.txt 68 | -------------------------------------------------------------------------------- /delegators.js: -------------------------------------------------------------------------------- 1 | const steem = require('steem'); 2 | var utils = require('./utils'); 3 | var dsteem = require('dsteem'); 4 | 5 | var delegation_transactions = []; 6 | 7 | function loadDelegations(client, account, callback) { 8 | getTransactions(client, account, -1, callback); 9 | } 10 | 11 | function getTransactions(client, account, start, callback) { 12 | var last_trans = start; 13 | utils.log('Loading history for delegators at transaction: ' + (start < 0 ? 'latest' : start)); 14 | 15 | client.database.call('get_account_history', [account, start, (start < 0) ? 10000 : Math.min(start, 10000)]).then(function (result) { 16 | result.reverse(); 17 | 18 | for(var i = 0; i < result.length; i++) { 19 | var trans = result[i]; 20 | var op = trans[1].op; 21 | 22 | if(op[0] == 'delegate_vesting_shares' && op[1].delegatee == account) 23 | delegation_transactions.push({ id: trans[0], data: op[1] }); 24 | 25 | // Save the ID of the last transaction that was processed. 26 | last_trans = trans[0]; 27 | } 28 | 29 | if(last_trans > 0 && last_trans != start) 30 | getTransactions(client, account, last_trans, callback); 31 | else { 32 | if(last_trans > 0) { 33 | utils.log('********* ALERT - Full account history not available from this node, not all delegators may have been loaded!! ********'); 34 | utils.log('********* Last available transaction was: ' + last_trans + ' ********'); 35 | } 36 | 37 | processDelegations(callback); 38 | } 39 | }, function(err) { console.log('Error loading account history for delegations: ' + err); }); 40 | } 41 | 42 | function processDelegations(callback) { 43 | var delegations = []; 44 | 45 | // Go through the delegation transactions from oldest to newest to find the final delegated amount from each account 46 | delegation_transactions.reverse(); 47 | 48 | for(var i = 0; i < delegation_transactions.length; i++) { 49 | var trans = delegation_transactions[i]; 50 | 51 | // Check if this is a new delegation or an update to an existing delegation from this account 52 | var delegation = delegations.find(d => d.delegator == trans.data.delegator); 53 | 54 | if(delegation) { 55 | delegation.vesting_shares = trans.data.vesting_shares; 56 | } else { 57 | delegations.push({ delegator: trans.data.delegator, vesting_shares: trans.data.vesting_shares }); 58 | } 59 | } 60 | 61 | delegation_transactions = []; 62 | 63 | // Return a list of all delegations (and filter out any that are 0) 64 | if(callback) 65 | callback(delegations.filter(function(d) { return parseFloat(d.vesting_shares) > 0; })); 66 | } 67 | 68 | module.exports = { 69 | loadDelegations: loadDelegations 70 | } 71 | -------------------------------------------------------------------------------- /config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "rpc_nodes": [ 3 | "https://api.steemit.com", 4 | "https://rpc.buildteam.io", 5 | "https://steemd.minnowsupportproject.org", 6 | "https://steemd.privex.io", 7 | "https://gtg.steem.house:8090" 8 | ], 9 | "backup_mode": false, 10 | "disabled_mode": false, 11 | "detailed_logging": false, 12 | "owner_account": "bot_owner_account_name", 13 | "account": "your_bot_account_name", 14 | "memo_key": "your_private_memo_key", 15 | "posting_key": "your_private_posting_key", 16 | "active_key": "your_private_active_key", 17 | "auto_claim_rewards" : true, 18 | "post_rewards_withdrawal_account": "withdraw_liquid_post_rewards_to_account", 19 | "min_bid": 0.1, 20 | "max_bid": 50, 21 | "max_bid_whitelist": 999, 22 | "batch_vote_weight": 100, 23 | "min_post_age": 20, 24 | "max_post_age": 84, 25 | "allow_comments": false, 26 | "currencies_accepted": ["SBD", "STEEM"], 27 | "refunds_enabled": true, 28 | "min_refund_amount": 0.002, 29 | "no_refund": ["bittrex", "poloniex", "openledger", "blocktrades", "minnowbooster", "ginabot"], 30 | "comment_location": "comment.md", 31 | "max_per_author_per_round": 1, 32 | "price_source": "bittrex", 33 | "blacklist_settings": { 34 | "flag_signal_accounts": ["spaminator", "cheetah", "steemcleaners", "mack-bot"], 35 | "blacklist_location": "blacklist", 36 | "shared_blacklist_location": "", 37 | "whitelist_location": "", 38 | "whitelist_only": false, 39 | "refund_blacklist": false, 40 | "blacklist_donation_account": "steemcleaners", 41 | "blacklisted_tags": ["nsfw"], 42 | "global_api_blacklists": ["buildawhale", "steemcleaners", "minnowbooster"] 43 | }, 44 | "auto_withdrawal": { 45 | "active": true, 46 | "accounts": [ 47 | { 48 | "name": "$delegators", 49 | "stake": 8000, 50 | "overrides": [ 51 | { "name": "delegator_account", "beneficiary": "beneficiary_account" } 52 | ] 53 | }, 54 | { 55 | "name": "yabapmatt", 56 | "stake": 2000 57 | } 58 | ], 59 | "frequency": "daily", 60 | "execute_time": 20, 61 | "memo": "# Daily Earnings - {balance} | Thank you!" 62 | }, 63 | "affiliates": [ 64 | { "name": "memo_prefix", "fee_pct": 150, "beneficiary": "payout_account" } 65 | ], 66 | "api": { 67 | "enabled": true, 68 | "port": 3000 69 | }, 70 | "transfer_memos": { 71 | "bot_disabled": "Refund for invalid bid: {amount} - The bot is currently disabled.", 72 | "below_min_bid": "Refund for invalid bid: {amount} - Min bid amount is {min_bid}.", 73 | "above_max_bid": "Refund for invalid bid: {amount} - Max bid amount is {max_bid}.", 74 | "above_max_bid_whitelist": "Refund for invalid bid: {amount} - Max bid amount for whitelisted users is {max_bid_whitelist}.", 75 | "invalid_currency": "Refund for invalid bid: {amount} - Bids in {currency} are not accepted.", 76 | "no_comments": "Refund for invalid bid: {amount} - Bids not allowed on comments.", 77 | "already_voted": "Refund for invalid bid: {amount} - Bot already voted on this post.", 78 | "max_age": "Refund for invalid bid: {amount} - Posts cannot be older than {max_age}.", 79 | "min_age": "Your bid has been added to the following round since the post is less than {min_age} minutes old.", 80 | "invalid_post_url": "Refund for invalid bid: {amount} - Invalid post URL in memo.", 81 | "blacklist_refund": "Refund for invalid bid: {amount} - The author of this post is on the blacklist.", 82 | "blacklist_no_refund": "Bid is invalid - The author of this post is on the blacklist.", 83 | "blacklist_donation": "Bid from blacklisted/flagged user sent as a donation. Thank you!", 84 | "flag_refund": "Refund for invalid bid: {amount} - This post has been flagged by one or more spam / abuse indicator accounts.", 85 | "flag_no_refund": "Bid is invalid - This post has been flagged by one or more spam / abuse indicator accounts.", 86 | "blacklist_tag": "Bid is invalid - This post contains the [{tag}] tag which is not allowed by this bot.", 87 | "bids_per_round": "Bid is invalid - This author already has the maximum number of allowed bids in this round.", 88 | "round_full": "The current bidding round is full. Your bid has been submitted into the following round.", 89 | "next_round_full": "Cannot deliver min return for this size bid in the current or next round. Please try a smaller bid amount.", 90 | "forward_payment": "Payment forwarded from @{tag}.", 91 | "whitelist_only": "Bid is invalid - Only posts by whitelisted authors are accepted by this bot.", 92 | "affiliate": "Affiliate payment - {tag}" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var dsteem = require('dsteem'); 3 | 4 | var STEEMIT_100_PERCENT = 10000; 5 | var STEEMIT_VOTE_REGENERATION_SECONDS = (5 * 60 * 60 * 24); 6 | var HOURS = 60 * 60; 7 | 8 | var steemPrice; 9 | var rewardBalance; 10 | var recentClaims; 11 | var currentUserAccount; 12 | var votePowerReserveRate; 13 | var totalVestingFund; 14 | var totalVestingShares; 15 | var steem_per_mvests; 16 | var sbd_print_percentage; 17 | 18 | function updateSteemVariables(client) { 19 | client.database.call('get_reward_fund', ['post']).then(function (t) { 20 | rewardBalance = parseFloat(t.reward_balance.replace(" STEEM", "")); 21 | recentClaims = t.recent_claims; 22 | }, function(e) { 23 | log('Error loading reward fund: ' + e); 24 | }); 25 | 26 | client.database.getCurrentMedianHistoryPrice().then(function (t) { 27 | steemPrice = parseFloat(t.base) / parseFloat(t.quote); 28 | }, function(e) { 29 | log('Error loading steem price: ' + e); 30 | }); 31 | 32 | client.database.getDynamicGlobalProperties().then(function (t) { 33 | votePowerReserveRate = t.vote_power_reserve_rate; 34 | totalVestingFund = parseFloat(t.total_vesting_fund_steem.replace(" STEEM", "")); 35 | totalVestingShares = parseFloat(t.total_vesting_shares.replace(" VESTS", "")); 36 | steem_per_mvests = ((totalVestingFund / totalVestingShares) * 1000000); 37 | sbd_print_percentage = t.sbd_print_rate / 10000 38 | }, function (e) { 39 | log('Error loading global properties: ' + e); 40 | }); 41 | 42 | setTimeout(function() { updateSteemVariables(client); }, 180 * 1000) 43 | } 44 | 45 | function vestsToSP(vests) { return vests / 1000000 * steem_per_mvests; } 46 | 47 | function getVotingPower(account) { 48 | var voting_power = account.voting_power; 49 | var last_vote_time = new Date((account.last_vote_time) + 'Z'); 50 | var elapsed_seconds = (new Date() - last_vote_time) / 1000; 51 | var regenerated_power = Math.round((STEEMIT_100_PERCENT * elapsed_seconds) / STEEMIT_VOTE_REGENERATION_SECONDS); 52 | var current_power = Math.min(voting_power + regenerated_power, STEEMIT_100_PERCENT); 53 | return current_power; 54 | } 55 | 56 | function getVPHF20(account) { 57 | var totalShares = parseFloat(account.vesting_shares) + parseFloat(account.received_vesting_shares) - parseFloat(account.delegated_vesting_shares); 58 | 59 | var elapsed = Date.now() / 1000 - account.voting_manabar.last_update_time; 60 | var maxMana = totalShares * 1000000; 61 | // 432000 sec = 5 days 62 | var currentMana = parseFloat(account.voting_manabar.current_mana) + elapsed * maxMana / 432000; 63 | 64 | if (currentMana > maxMana) { 65 | currentMana = maxMana; 66 | } 67 | 68 | var currentManaPerc = currentMana * 100 / maxMana; 69 | 70 | return Math.round(currentManaPerc * 100); 71 | } 72 | 73 | function getVoteRShares(voteWeight, account, power) { 74 | if (!account) { 75 | return; 76 | } 77 | 78 | if (rewardBalance && recentClaims && steemPrice && votePowerReserveRate) { 79 | 80 | var effective_vesting_shares = Math.round(getVestingShares(account) * 1000000); 81 | var voting_power = account.voting_power; 82 | var weight = voteWeight * 100; 83 | var last_vote_time = new Date((account.last_vote_time) + 'Z'); 84 | 85 | 86 | var elapsed_seconds = (new Date() - last_vote_time) / 1000; 87 | var regenerated_power = Math.round((STEEMIT_100_PERCENT * elapsed_seconds) / STEEMIT_VOTE_REGENERATION_SECONDS); 88 | var current_power = power || Math.min(voting_power + regenerated_power, STEEMIT_100_PERCENT); 89 | var max_vote_denom = votePowerReserveRate * STEEMIT_VOTE_REGENERATION_SECONDS / (60 * 60 * 24); 90 | var used_power = Math.round((current_power * weight) / STEEMIT_100_PERCENT); 91 | used_power = Math.round((used_power + max_vote_denom - 1) / max_vote_denom); 92 | 93 | var rshares = Math.round((effective_vesting_shares * used_power) / (STEEMIT_100_PERCENT)) 94 | 95 | return rshares; 96 | 97 | } 98 | } 99 | 100 | function getVoteValue(voteWeight, account, power, steem_price) { 101 | if (!account) { 102 | return; 103 | } 104 | if (rewardBalance && recentClaims && steemPrice && votePowerReserveRate) { 105 | var voteValue = getVoteRShares(voteWeight, account, power) 106 | * rewardBalance / recentClaims 107 | * steem_price; 108 | 109 | return voteValue; 110 | 111 | } 112 | } 113 | 114 | function getVoteValueUSD(vote_value, sbd_price) { 115 | const steempower_value = vote_value * 0.5 116 | const sbd_print_percentage_half = (0.5 * sbd_print_percentage) 117 | const sbd_value = vote_value * sbd_print_percentage_half 118 | const steem_value = vote_value * (0.5 - sbd_print_percentage_half) 119 | return (sbd_value * sbd_price) + steem_value + steempower_value 120 | } 121 | 122 | function timeTilFullPower(cur_power){ 123 | return (STEEMIT_100_PERCENT - cur_power) * STEEMIT_VOTE_REGENERATION_SECONDS / STEEMIT_100_PERCENT; 124 | } 125 | 126 | function getVestingShares(account) { 127 | var effective_vesting_shares = parseFloat(account.vesting_shares.replace(" VESTS", "")) 128 | + parseFloat(account.received_vesting_shares.replace(" VESTS", "")) 129 | - parseFloat(account.delegated_vesting_shares.replace(" VESTS", "")); 130 | return effective_vesting_shares; 131 | } 132 | 133 | function getCurrency(amount) { 134 | return amount.substr(amount.indexOf(' ') + 1); 135 | } 136 | 137 | function loadUserList(location, callback) { 138 | if(!location) { 139 | if(callback) 140 | callback(null); 141 | 142 | return; 143 | } 144 | 145 | if (location.startsWith('http://') || location.startsWith('https://')) { 146 | // Require the "request" library for making HTTP requests 147 | var request = require("request"); 148 | 149 | request.get(location, function (e, r, data) { 150 | try { 151 | if(callback) 152 | callback(data.replace(/[\r]/g, '').split('\n')); 153 | } catch (err) { 154 | log('Error loading blacklist from: ' + location + ', Error: ' + err); 155 | 156 | if(callback) 157 | callback(null); 158 | } 159 | }); 160 | } else if (fs.existsSync(location)) { 161 | if(callback) 162 | callback(fs.readFileSync(location, "utf8").replace(/[\r]/g, '').split('\n')); 163 | } else if(callback) 164 | callback([]); 165 | } 166 | 167 | function format(n, c, d, t) { 168 | var c = isNaN(c = Math.abs(c)) ? 2 : c, 169 | d = d == undefined ? "." : d, 170 | t = t == undefined ? "," : t, 171 | s = n < 0 ? "-" : "", 172 | i = String(parseInt(n = Math.abs(Number(n) || 0).toFixed(c))), 173 | j = (j = i.length) > 3 ? j % 3 : 0; 174 | return s + (j ? i.substr(0, j) + t : "") + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + t) + (c ? d + Math.abs(n - i).toFixed(c).slice(2) : ""); 175 | } 176 | 177 | function toTimer(ts) { 178 | var h = Math.floor(ts / HOURS); 179 | var m = Math.floor((ts % HOURS) / 60); 180 | var s = Math.floor((ts % 60)); 181 | return padLeft(h, 2) + ':' + padLeft(m, 2) + ':' + padLeft(s, 2); 182 | } 183 | 184 | function padLeft(v, d) { 185 | var l = (v + '').length; 186 | if (l >= d) return v + ''; 187 | for(var i = l; i < d; i++) 188 | v = '0' + v; 189 | return v; 190 | } 191 | 192 | function log(msg) { console.log(new Date().toString() + ' - ' + msg); } 193 | 194 | module.exports = { 195 | updateSteemVariables: updateSteemVariables, 196 | getVotingPower: getVotingPower, 197 | getVoteValue: getVoteValue, 198 | getVoteValueUSD: getVoteValueUSD, 199 | timeTilFullPower: timeTilFullPower, 200 | getVestingShares: getVestingShares, 201 | vestsToSP: vestsToSP, 202 | loadUserList: loadUserList, 203 | getCurrency: getCurrency, 204 | format: format, 205 | toTimer: toTimer, 206 | log: log, 207 | getVPHF20: getVPHF20 208 | } 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Post Promoter - Steem Bid-Based Voting Bot 2 | 3 | ## Installation 4 | ``` 5 | $ git clone https://github.com/MattyIce/postpromoter.git 6 | $ npm install 7 | ``` 8 | 9 | ## Configuration 10 | First rename config-example.json to config.json: 11 | ``` 12 | $ mv config-example.json config.json 13 | ``` 14 | 15 | Then set the following options in config.json: 16 | ``` 17 | { 18 | "rpc_nodes": // Set the list of RPC nodes you would like to connect to (https://api.steemit.com is the default if this is not set). The software will automatically fail over to the next node on the list if the current one is having issues. 19 | [ 20 | "https://api.steemit.com", 21 | "https://rpc.buildteam.io", 22 | "https://steemd.minnowsupportproject.org", 23 | "https://steemd.privex.io", 24 | "https://gtg.steem.house:8090" 25 | ], 26 | "backup_mode": false, // Put the bot in "backup mode" where it will process all bids and account state but not vote or transact 27 | "disabled_mode": false, // Set this to true to refund all funds sent to the bot 28 | "detailed_logging": false, // Whether or not detailed logging is enabled 29 | "owner_account": "bot_owner_account", // The name of the bot owner account (can be left null or blank) 30 | "account": "yourbotaccount", 31 | "memo_key": "your_private_memo_key" 32 | "posting_key": "your_private_posting_key", 33 | "active_key": "your_private_active_key", 34 | "auto_claim_rewards" : true, // Set to false if you don't want to claim rewards automatically 35 | "post_rewards_withdrawal_account": "account_name", // Automatically withdraw any liquid post rewards to the specified account 36 | "min_bid": 0.1, 37 | "max_bid": 50, // Maximum bid amount for regular users 38 | "max_bid_whitelist": 999, // Maximum bid amount for whitelisted users 39 | "min_resteem": 1, // If a bid is sent for this amount or more then the bot will resteem the post 40 | "max_roi": 10, // If too few votes come in this will limit the bot's vote weight so that no more than a 10% ROI is given for votes 41 | "round_fill_limit": 0.9, // Limit the round to 90% full to guarantee a minimum of 10% ROI for all bidders 42 | "batch_vote_weight": 100, 43 | "min_post_age": 20, // In minutes, minimum age of post that will be accepted 44 | "max_post_age": 144, // In hours, 144 hours = 6 days 45 | "allow_comments": true, 46 | "currencies_accepted": ["SBD", "STEEM"], // Which currencies to accept 47 | "refunds_enabled": true, 48 | "min_refund_amount": 0.002, // This will prevent refunds for transfer memos 49 | "no_refund": ["bittrex", "poloniex", "openledger", "blocktrades", "minnowbooster"], // Don't refund transactions from these accounts! 50 | "max_per_author_per_round": 1, // Limit to the number of posts that can be voted on for a particular author each round 51 | "comment_location": "comment.md", // The location of a markdown file containing the comment that should be left after the bot votes on a post. Leave this null or blank for no comment. 52 | "price_source": "bittrex", // Where to load STEEM/SBD prices. Default is 'bittrex'. Also can use 'coinmarketcap' or a custom prices API URL 53 | "blacklist_settings": { 54 | "flag_signal_accounts": ["spaminator", "cheetah", "steemcleaners", "mack-bot"], // If any accounts on this list has flagged the post at the time the bid comes in it will be treated as blacklisted 55 | "blacklist_location": "blacklist", // The location of the blacklist file containing one blacklisted Steem account name per line 56 | "shared_blacklist_location": "http://somesite.org/steemblacklist", // The location of a shared blacklist URL which just returns a text file containing one blacklisted Steem account name per line 57 | "whitelist_location": "whitelist", // The location of the whitelist file containing one blacklisted Steem account name per line, this will override the blacklist 58 | "whitelist_only": false, // Whether or not the bot will only allow bids for posts by whitelisted accounts 59 | "refund_blacklist": true, // Whether or not to refund blacklisted users' bids 60 | "blacklist_donation_account": "steemcleaners", // If "refund_blacklist" is false, then this will send all bids from blacklisted users to the specified account as a donation 61 | "blacklisted_tags": ["nsfw", "other-tag"], // List of post tags that are not allowed by the bot. Bids for posts with one or more tags in this list will be refunded 62 | "global_api_blacklists": ["buildawhale", "steemcleaners", "minnowbooster"] // global blacklist API http://blacklist.usesteem.com 63 | }, 64 | "auto_withdrawal": { 65 | "active": true, // Activate the auto withdrawal function (will withdraw all accepted currencies) 66 | "accounts": [ // List of accounts to receive daily withdrawals and the amount to send to each 67 | { 68 | "name": "$delegators", // Use the special name '$delegators' to split this portion of the payout among all delegators to the account based on the amount of their delegation 69 | "stake": 8000, 70 | "overrides": [ // Specify a beneficiary account for payments for certain delegators 71 | { "name": "delegator_account", "beneficiary": "beneficiary_account" } 72 | ] 73 | }, 74 | { 75 | "name": "account2", 76 | "stake": 2000 77 | } 78 | ], 79 | "frequency": "daily", // This can be "daily" for withdrawals once per day or "round_end" for withdrawals after every bidding round 80 | "execute_time": 20, // Hour of the day to execute the withdrawal (0 - 23) 81 | "memo": "#Today generated SBD - {balance} | Thank you." // Transaction memo, start with # if you want it encrypted 82 | }, 83 | "api": { // This will expose an API endpoint for information about bids in each round 84 | "enabled": true, 85 | "port": 3000 86 | }, 87 | "transfer_memos": { // Memos sent with transfer for bid refunds 88 | "bot_disabled": "Refund for invalid bid: {amount} - The bot is currently disabled.", 89 | "below_min_bid": "Refund for invalid bid: {amount} - Min bid amount is {min_bid}.", 90 | "above_max_bid": "Refund for invalid bid: {amount} - Max bid amount is {max_bid}.", 91 | "above_max_bid_whitelist": "Refund for invalid bid: {amount} - Max bid amount for whitelisted users is {max_bid_whitelist}.", 92 | "invalid_currency": "Refund for invalid bid: {amount} - Bids in {currency} are not accepted.", 93 | "no_comments": "Refund for invalid bid: {amount} - Bids not allowed on comments.", 94 | "already_voted": "Refund for invalid bid: {amount} - Bot already voted on this post.", 95 | "max_age": "Refund for invalid bid: {amount} - Posts cannot be older than {max_age}.", 96 | "min_age": "Your bid has been added to the following round since the post is less than {min_age} minutes old.", 97 | "invalid_post_url": "Refund for invalid bid: {amount} - Invalid post URL in memo.", 98 | "blacklist_refund": "Refund for invalid bid: {amount} - The author of this post is on the blacklist.", 99 | "blacklist_no_refund": "Bid is invalid - The author of this post is on the blacklist.", 100 | "blacklist_donation": "Bid from blacklisted/flagged user sent as a donation. Thank you!", 101 | "flag_refund": "Refund for invalid bid: {amount} - This post has been flagged by one or more spam / abuse indicator accounts.", 102 | "flag_no_refund": "Bid is invalid - This post has been flagged by one or more spam / abuse indicator accounts.", 103 | "blacklist_tag": "Bid is invalid - This post contains the [{tag}] tag which is not allowed by this bot.", 104 | "bids_per_round": "Bid is invalid - This author already has the maximum number of allowed bids in this round.", 105 | "round_full": "The current bidding round is full. Your bid has been submitted into the following round.", 106 | "next_round_full": "Cannot deliver min return for this size bid in the current or next round. Please try a smaller bid amount.", 107 | "forward_payment": "Payment forwarded from @{tag}." 108 | "bid_confirmation": "Your bid is confirmed. You will receive your vote when the bot reaches 100% voting power. Thank you!", 109 | "delegation": "Thank you for your delegation of {tag} SP! You will start to receive payouts after the next withdrawal.", 110 | "whitelist_only": "Bid is invalid - Only posts by whitelisted authors are accepted by this bot." 111 | } 112 | } 113 | ``` 114 | 115 | ### Blacklist 116 | You can add a list of blacklisted users whose bids will not be accepted and who will not receive any refund by adding their steem account name to the "blacklist" file. Set the "blacklist_location" property to point to the location of your blacklist file, or you can use a URL to point to a shared blacklist on the internet. The file should contain only one steem account name on each line and nothing else as in the following example: 117 | 118 | ``` 119 | blacklisted_account_1 120 | blacklisted_account_2 121 | blacklisted_account_3 122 | ``` 123 | 124 | Additionally you can add a list of "flag_signal_accounts" which means that if any accounts on that list have flagged the post at the time the bid is sent then the bot will treat it as blacklisted. 125 | 126 | ## Run 127 | ``` 128 | $ nodejs postpromoter.js 129 | ``` 130 | 131 | This will run the process in the foreground which is not recommended. We recommend using a tool such as [PM2](http://pm2.keymetrics.io/) to run the process in the background as well as providing many other great features. 132 | 133 | ## API Setup 134 | If you would like to use the API functionality set the "api.enabled" setting to "true" and choose a port. You can test if it is working locally by running: 135 | 136 | ``` 137 | $ curl http://localhost:port/api/bids 138 | ``` 139 | 140 | If that returns a JSON object with bids then it is working. 141 | 142 | It is recommended to set up an nginx reverse proxy server (or something similar) to forward requests on port 80 to the postpromoter nodejs server. For instructions on how to do that please see: https://medium.com/@utkarsh_verma/configure-nginx-as-a-web-server-and-reverse-proxy-for-nodejs-application-on-aws-ubuntu-16-04-server-872922e21d38 143 | 144 | In order to be used on the bot tracker website it will also need an SSL certificate. For instructions to get and install a free SSL certificate see: https://certbot.eff.org/ 145 | -------------------------------------------------------------------------------- /postpromoter.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var request = require("request"); 3 | var steem = require('steem'); 4 | var dsteem = require('dsteem'); 5 | var utils = require('./utils'); 6 | 7 | var account = null; 8 | var transactions = []; 9 | var outstanding_bids = []; 10 | var delegators = []; 11 | var last_round = []; 12 | var next_round = []; 13 | var blacklist = []; 14 | var whitelist = []; 15 | var config = null; 16 | var first_load = true; 17 | var isVoting = false; 18 | var last_withdrawal = null; 19 | var use_delegators = false; 20 | var round_end_timeout = -1; 21 | var steem_price = 1; // This will get overridden with actual prices if a price_feed_url is specified in settings 22 | var sbd_price = 1; // This will get overridden with actual prices if a price_feed_url is specified in settings 23 | var version = '2.1.1'; 24 | var client = null; 25 | var rpc_node = null; 26 | 27 | const AUTHOR_PCT = 0.5; 28 | 29 | startup(); 30 | 31 | function startup() { 32 | // Load the settings from the config file 33 | loadConfig(); 34 | 35 | // Connect to the specified RPC node 36 | rpc_node = config.rpc_nodes ? config.rpc_nodes[0] : (config.rpc_node ? config.rpc_node : 'https://api.steemit.com'); 37 | client = new dsteem.Client(rpc_node); 38 | 39 | utils.log("* START - Version: " + version + " *"); 40 | utils.log("Connected to: " + rpc_node); 41 | 42 | if(config.backup_mode) 43 | utils.log('*** RUNNING IN BACKUP MODE ***'); 44 | 45 | // Load Steem global variables 46 | utils.updateSteemVariables(client); 47 | 48 | // If the API is enabled, start the web server 49 | if(config.api && config.api.enabled) { 50 | var express = require('express'); 51 | var app = express(); 52 | var port = process.env.PORT || config.api.port 53 | 54 | app.use(function(req, res, next) { 55 | res.header("Access-Control-Allow-Origin", "*"); 56 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); 57 | next(); 58 | }); 59 | 60 | app.get('/api/bids', (req, res) => res.json({ current_round: outstanding_bids, last_round: last_round })); 61 | app.listen(port, () => utils.log('API running on port ' + port)) 62 | } 63 | 64 | // Check if bot state has been saved to disk, in which case load it 65 | if (fs.existsSync('state.json')) { 66 | var state = JSON.parse(fs.readFileSync("state.json")); 67 | 68 | if (state.transactions) 69 | transactions = state.transactions; 70 | 71 | if (state.outstanding_bids) 72 | outstanding_bids = state.outstanding_bids; 73 | 74 | if (state.last_round) 75 | last_round = state.last_round; 76 | 77 | if (state.next_round) 78 | next_round = state.next_round; 79 | 80 | if(state.last_withdrawal) 81 | last_withdrawal = state.last_withdrawal; 82 | 83 | // Removed this for now since api.steemit.com is not returning more than 30 days of account history! 84 | //if(state.version != version) 85 | // updateVersion(state.version, version); 86 | 87 | utils.log('Restored saved bot state: ' + JSON.stringify({ last_trx_id: (transactions.length > 0 ? transactions[transactions.length - 1] : ''), bids: outstanding_bids.length, last_withdrawal: last_withdrawal })); 88 | } 89 | 90 | // Check whether or not auto-withdrawals are set to be paid to delegators. 91 | use_delegators = config.auto_withdrawal && config.auto_withdrawal.active && config.auto_withdrawal.accounts.find(a => a.name == '$delegators'); 92 | 93 | // If so then we need to load the list of delegators to the account 94 | if(use_delegators) { 95 | if(fs.existsSync('delegators.json')) { 96 | delegators = JSON.parse(fs.readFileSync("delegators.json")); 97 | 98 | var vests = delegators.reduce(function (total, v) { return total + parseFloat(v.vesting_shares); }, 0); 99 | utils.log('Delegators Loaded (from disk) - ' + delegators.length + ' delegators and ' + vests + ' VESTS in total!'); 100 | } 101 | else 102 | { 103 | var del = require('./delegators'); 104 | utils.log('Started loading delegators from account history...'); 105 | del.loadDelegations(client, config.account, function(d) { 106 | delegators = d; 107 | var vests = delegators.reduce(function (total, v) { return total + parseFloat(v.vesting_shares); }, 0); 108 | utils.log('Delegators Loaded (from account history) - ' + delegators.length + ' delegators and ' + vests + ' VESTS in total!'); 109 | 110 | // Save the list of delegators to disk 111 | saveDelegators(); 112 | }); 113 | } 114 | } 115 | 116 | // Schedule to run every 10 seconds 117 | setInterval(startProcess, 10000); 118 | 119 | // Load updated STEEM and SBD prices every 30 minutes 120 | loadPrices(); 121 | setInterval(loadPrices, 30 * 60 * 1000); 122 | } 123 | 124 | function startProcess() { 125 | // Load the settings from the config file each time so we can pick up any changes 126 | loadConfig(); 127 | 128 | // Load the bot account info 129 | client.database.getAccounts([config.account]).then(function (result) { 130 | account = result[0]; 131 | 132 | if (account && !isVoting) { 133 | var vp = utils.getVotingPower(account); 134 | 135 | if(config.detailed_logging) { 136 | var bids_steem = utils.format(outstanding_bids.reduce(function(t, b) { return t + ((b.currency == 'STEEM') ? b.amount : 0); }, 0), 3); 137 | var bids_sbd = utils.format(outstanding_bids.reduce(function(t, b) { return t + ((b.currency == 'SBD') ? b.amount : 0); }, 0), 3); 138 | utils.log((config.backup_mode ? '* BACKUP MODE *' : '') + 'Voting Power: ' + utils.format(vp / 100) + '% | Time until next round: ' + utils.toTimer(utils.timeTilFullPower(vp)) + ' | Bids: ' + outstanding_bids.length + ' | ' + bids_sbd + ' SBD | ' + bids_steem + ' STEEM'); 139 | } 140 | 141 | // We are at 100% voting power - time to vote! 142 | if (vp >= 10000 && outstanding_bids.length > 0 && round_end_timeout < 0) { 143 | round_end_timeout = setTimeout(function() { 144 | round_end_timeout = -1; 145 | 146 | // Don't process any bids while we are voting due to race condition (they will be processed when voting is done). 147 | isVoting = first_load = true; 148 | 149 | // Make a copy of the list of outstanding bids and vote on them 150 | startVoting(outstanding_bids.slice().reverse()); 151 | 152 | // Save the last round of bids for use in API call 153 | last_round = outstanding_bids.slice(); 154 | 155 | // Some bids might have been pushed to the next round, so now move them to the current round 156 | outstanding_bids = next_round.slice(); 157 | 158 | // Reset the next round 159 | next_round = []; 160 | 161 | // Send out earnings if frequency is set to every round 162 | if (config.auto_withdrawal.frequency == 'round_end') 163 | processWithdrawals(); 164 | 165 | // Save the state of the bot to disk 166 | saveState(); 167 | }, 30 * 1000); 168 | } 169 | 170 | // Load transactions to the bot account 171 | getTransactions(saveState); 172 | 173 | // Check if there are any rewards to claim. 174 | claimRewards(); 175 | 176 | // Check if it is time to withdraw funds. 177 | if (config.auto_withdrawal.frequency == 'daily') 178 | checkAutoWithdraw(); 179 | } 180 | }, function(err) { 181 | logError('Error loading bot account: ' + err); 182 | }); 183 | } 184 | 185 | function startVoting(bids) { 186 | if(config.backup_mode) { 187 | utils.log('*** Bidding Round End - Backup Mode, no voting ***'); 188 | setTimeout(function () { isVoting = false; first_load = true; }, 5 * 60 * 1000); 189 | return; 190 | } 191 | 192 | // Sum the amounts of all of the bids 193 | var total = bids.reduce(function(total, bid) { 194 | return total + getUsdValue(bid); 195 | }, 0); 196 | 197 | var bids_steem = utils.format(outstanding_bids.reduce(function(t, b) { return t + ((b.currency == 'STEEM') ? b.amount : 0); }, 0), 3); 198 | var bids_sbd = utils.format(outstanding_bids.reduce(function(t, b) { return t + ((b.currency == 'SBD') ? b.amount : 0); }, 0), 3); 199 | utils.log('======================================================='); 200 | utils.log('Bidding Round End! Starting to vote! Total bids: ' + bids.length + ' - $' + total + ' | ' + bids_sbd + ' SBD | ' + bids_steem + ' STEEM'); 201 | 202 | var adjusted_weight = 1; 203 | 204 | if(config.max_roi != null && config.max_roi != undefined && !isNaN(config.max_roi)) { 205 | var vote_value = utils.getVoteValue(100, account, 10000, steem_price); 206 | var vote_value_usd = utils.getVoteValueUSD(vote_value, sbd_price) 207 | //min_total_bids_value_usd: calculates the minimum value in USD that the total bids must have to represent a maximum ROI defined in config.json 208 | //'max_roi' in config.json = 10 represents a maximum ROI of 10% 209 | var min_total_bids_value_usd = vote_value_usd * AUTHOR_PCT * ((100 - config.max_roi) / 100 ); 210 | // calculates the value of the weight of the vote needed to give the maximum ROI defined 211 | adjusted_weight = (total < min_total_bids_value_usd) ? (total / min_total_bids_value_usd) : 1; 212 | utils.log('Total vote weight: ' + (config.batch_vote_weight * adjusted_weight)); 213 | } 214 | 215 | utils.log('======================================================='); 216 | 217 | for(var i = 0; i < bids.length; i++) { 218 | // Calculate the vote weight to be used for each bid based on the amount bid as a percentage of the total bids 219 | bids[i].weight = Math.round(config.batch_vote_weight * adjusted_weight * 100 * (getUsdValue(bids[i]) / total)); 220 | } 221 | 222 | comment(bids.slice()); 223 | vote(bids); 224 | } 225 | 226 | function vote(bids) { 227 | // Get the first bid in the list 228 | sendVote(bids.pop(), 0, function () { 229 | // If there are more bids, vote on the next one after 10 seconds 230 | if (bids.length > 0) { 231 | setTimeout(function () { vote(bids); }, 5000); 232 | } else { 233 | setTimeout(function () { 234 | utils.log('======================================================='); 235 | utils.log('Voting Complete!'); 236 | utils.log('======================================================='); 237 | isVoting = false; 238 | first_load = true; 239 | }, 5000); 240 | } 241 | }); 242 | } 243 | 244 | function comment(bids) { 245 | sendComment(bids.pop()); 246 | 247 | if(bids.length > 0) 248 | setTimeout(function () { comment(bids); }, 30000); 249 | } 250 | 251 | function sendVote(bid, retries, callback) { 252 | utils.log('Casting: ' + utils.format(bid.weight / 100) + '% vote cast for: @' + bid.author + '/' + bid.permlink); 253 | 254 | validatePost(bid.author, bid.permlink, true, function(e) { 255 | if(e) { 256 | utils.log('Post @' + bid.author + '/' + bid.permlink + ' is invalid for reason: ' + e); 257 | 258 | if(callback) 259 | callback(); 260 | } else { 261 | client.broadcast.vote({ voter: account.name, author: bid.author, permlink: bid.permlink, weight: bid.weight }, dsteem.PrivateKey.fromString(config.posting_key)).then(function(result) { 262 | if (result) { 263 | utils.log(utils.format(bid.weight / 100) + '% vote cast for: @' + bid.author + '/' + bid.permlink); 264 | 265 | if (callback) 266 | callback(); 267 | } 268 | }, function(err) { 269 | logError('Error sending vote for: @' + bid.author + '/' + bid.permlink + ', Error: ' + err); 270 | 271 | // Try again on error 272 | if(retries < 2) 273 | setTimeout(function() { sendVote(bid, retries + 1, callback); }, 10000); 274 | else { 275 | utils.log('============= Vote transaction failed three times for: @' + bid.author + '/' + bid.permlink + ' Bid Amount: ' + bid.amount + ' ' + bid.currency + ' ==============='); 276 | logFailedBid(bid, err); 277 | 278 | if (callback) 279 | callback(); 280 | } 281 | }); 282 | } 283 | }); 284 | } 285 | 286 | function sendComment(bid) { 287 | var content = null; 288 | 289 | if(config.comment_location && config.comment_location != '') { 290 | content = fs.readFileSync(config.comment_location, "utf8"); 291 | } else if (config.promotion_content && config.promotion_content != '') { 292 | content = config.promotion_content; 293 | } 294 | 295 | // If promotion content is specified in the config then use it to comment on the upvoted post 296 | if (content && content != '') { 297 | 298 | // Generate the comment permlink via steemit standard convention 299 | var permlink = 're-' + bid.author.replace(/\./g, '') + '-' + bid.permlink + '-' + new Date().toISOString().replace(/-|:|\./g, '').toLowerCase(); 300 | 301 | // Replace variables in the promotion content 302 | content = content.replace(/\{weight\}/g, utils.format(bid.weight / 100)).replace(/\{botname\}/g, config.account).replace(/\{sender\}/g, bid.sender); 303 | 304 | var comment = { 305 | author: account.name, 306 | permlink: permlink, 307 | parent_author: bid.author, 308 | parent_permlink: bid.permlink, 309 | title: permlink, 310 | body: content, 311 | json_metadata: '{"app":"postpromoter/' + version + '"}' 312 | }; 313 | 314 | // Broadcast the comment 315 | client.broadcast.comment(comment, dsteem.PrivateKey.fromString(config.posting_key)).then(function (result) { 316 | if (result) 317 | utils.log('Posted comment: ' + permlink); 318 | }, function(err) { logError('Error posting comment: ' + permlink); }); 319 | } 320 | 321 | // Check if the bot should resteem this post 322 | if (config.min_resteem && bid.amount >= config.min_resteem) 323 | resteem(bid); 324 | } 325 | 326 | function resteem(bid) { 327 | var json = JSON.stringify(['reblog', { 328 | account: config.account, 329 | author: bid.author, 330 | permlink: bid.permlink 331 | }]); 332 | 333 | client.broadcast.json({ id: 'follow', json: json, required_auths: [], required_posting_auths: [config.account] }, dsteem.PrivateKey.fromString(config.posting_key)).then(function(result) { 334 | if (result) 335 | utils.log('Resteemed Post: @' + bid.sender + '/' + bid.permlink); 336 | }, function(err) { 337 | utils.log('Error resteeming post: @' + bid.sender + '/' + bid.permlink); 338 | }); 339 | } 340 | 341 | function getTransactions(callback) { 342 | var last_trx_id = null; 343 | var num_trans = 50; 344 | 345 | // If this is the first time the bot is ever being run, start with just the most recent transaction 346 | if (first_load && transactions.length == 0) { 347 | utils.log('First run - starting with last transaction on account.'); 348 | } 349 | 350 | // If this is the first time the bot is run after a restart get a larger list of transactions to make sure none are missed 351 | if (first_load && transactions.length > 0) { 352 | utils.log('First run - loading all transactions since last transaction processed: ' + transactions[transactions.length - 1]); 353 | last_trx_id = transactions[transactions.length - 1]; 354 | num_trans = 1000; 355 | } 356 | 357 | client.database.call('get_account_history', [account.name, -1, num_trans]).then(function (result) { 358 | // On first load, just record the list of the past 50 transactions so we don't double-process them. 359 | if (first_load && transactions.length == 0) { 360 | transactions = result.map(r => r[1].trx_id).filter(t => t != '0000000000000000000000000000000000000000'); 361 | first_load = false; 362 | 363 | utils.log(transactions.length + ' previous trx_ids recorded.'); 364 | 365 | if(callback) 366 | callback(); 367 | 368 | return; 369 | } 370 | 371 | first_load = false; 372 | var reached_starting_trx = false; 373 | 374 | for (var i = 0; i < result.length; i++) { 375 | var trans = result[i]; 376 | var op = trans[1].op; 377 | 378 | // Don't need to process virtual ops 379 | if(trans[1].trx_id == '0000000000000000000000000000000000000000') 380 | continue; 381 | 382 | // Check that this is a new transaction that we haven't processed already 383 | if(transactions.indexOf(trans[1].trx_id) < 0) { 384 | 385 | // If the bot was restarted after being stopped for a while, don't process transactions until we're past the last trx_id that was processed 386 | if(last_trx_id && !reached_starting_trx) { 387 | if(trans[1].trx_id == last_trx_id) 388 | reached_starting_trx = true; 389 | 390 | continue; 391 | } 392 | 393 | if(config.debug_logging) 394 | utils.log('Processing Transaction: ' + JSON.stringify(trans)); 395 | 396 | // We only care about transfers to the bot 397 | if (op[0] == 'transfer' && op[1].to == config.account) { 398 | var amount = parseFloat(op[1].amount); 399 | var currency = utils.getCurrency(op[1].amount); 400 | utils.log("Incoming Bid! From: " + op[1].from + ", Amount: " + op[1].amount + ", memo: " + op[1].memo); 401 | 402 | // Check for min and max bid values in configuration settings 403 | var min_bid = config.min_bid ? parseFloat(config.min_bid) : 0; 404 | var max_bid = config.max_bid ? parseFloat(config.max_bid) : 9999; 405 | var max_bid_whitelist = config.max_bid_whitelist ? parseFloat(config.max_bid_whitelist) : 9999; 406 | 407 | if(config.disabled_mode) { 408 | // Bot is disabled, refund all Bids 409 | refund(op[1].from, amount, currency, 'bot_disabled'); 410 | } else if(amount < min_bid) { 411 | // Bid amount is too low (make sure it's above the min_refund_amount setting) 412 | if(!config.min_refund_amount || amount >= config.min_refund_amount) 413 | refund(op[1].from, amount, currency, 'below_min_bid'); 414 | else { 415 | utils.log('Invalid bid - below min bid amount and too small to refund.'); 416 | } 417 | } else if (amount > max_bid && whitelist.indexOf(op[1].from) < 0) { 418 | // Bid amount is too high 419 | refund(op[1].from, amount, currency, 'above_max_bid'); 420 | } else if (amount > max_bid_whitelist) { 421 | // Bid amount is too high even for whitelisted users! 422 | refund(op[1].from, amount, currency, 'above_max_bid_whitelist'); 423 | } else if(config.currencies_accepted && config.currencies_accepted.indexOf(currency) < 0) { 424 | // Sent an unsupported currency 425 | refund(op[1].from, amount, currency, 'invalid_currency'); 426 | } else { 427 | // Bid amount is just right! 428 | checkPost(op[1].memo, amount, currency, op[1].from, 0); 429 | } 430 | } else if(use_delegators && op[0] == 'delegate_vesting_shares' && op[1].delegatee == account.name) { 431 | // If we are paying out to delegators, then update the list of delegators when new delegation transactions come in 432 | var delegator = delegators.find(d => d.delegator == op[1].delegator); 433 | 434 | if(delegator) 435 | delegator.new_vesting_shares = op[1].vesting_shares; 436 | else { 437 | delegator = { delegator: op[1].delegator, vesting_shares: 0, new_vesting_shares: op[1].vesting_shares }; 438 | delegators.push(delegator); 439 | } 440 | 441 | // Save the updated list of delegators to disk 442 | saveDelegators(); 443 | 444 | // Check if we should send a delegation message 445 | if(parseFloat(delegator.new_vesting_shares) > parseFloat(delegator.vesting_shares) && config.transfer_memos['delegation'] && config.transfer_memos['delegation'] != '') 446 | refund(op[1].delegator, 0.001, 'SBD', 'delegation', 0, utils.vestsToSP(parseFloat(delegator.new_vesting_shares)).toFixed()); 447 | 448 | utils.log('*** Delegation Update - ' + op[1].delegator + ' has delegated ' + op[1].vesting_shares); 449 | } 450 | 451 | // Save the ID of the last transaction that was processed. 452 | transactions.push(trans[1].trx_id); 453 | 454 | // Don't save more than the last 60 transaction IDs in the state 455 | if(transactions.length > 60) 456 | transactions.shift(); 457 | } 458 | } 459 | 460 | if (callback) 461 | callback(); 462 | }, function(err) { 463 | logError('Error loading account history: ' + err); 464 | 465 | if (callback) 466 | callback(); 467 | }); 468 | } 469 | 470 | function checkRoundFillLimit(round, amount, currency) { 471 | if(config.round_fill_limit == null || config.round_fill_limit == undefined || isNaN(config.round_fill_limit)) 472 | return false; 473 | 474 | var vote_value = utils.getVoteValue(100, account, 10000, steem_price); 475 | var vote_value_usd = utils.getVoteValueUSD(vote_value, sbd_price) 476 | var bid_value = round.reduce(function(t, b) { return t + b.amount * ((b.currency == 'SBD') ? sbd_price : steem_price) }, 0); 477 | var new_bid_value = amount * ((currency == 'SBD') ? sbd_price : steem_price); 478 | 479 | // Check if the value of the bids is over the round fill limit 480 | return (vote_value_usd * AUTHOR_PCT * config.round_fill_limit < bid_value + new_bid_value); 481 | } 482 | 483 | function validatePost(author, permlink, isVoting, callback, retries) { 484 | client.database.call('get_content', [author, permlink]).then(function (result) { 485 | if (result && result.id > 0) { 486 | 487 | // If comments are not allowed then we need to first check if the post is a comment 488 | if(!config.allow_comments && (result.parent_author != null && result.parent_author != '')) { 489 | if(callback) 490 | callback('no_comments'); 491 | 492 | return; 493 | } 494 | 495 | // Check if any tags on this post are blacklisted in the settings 496 | if (config.blacklist_settings.blacklisted_tags && config.blacklist_settings.blacklisted_tags.length > 0 && result.json_metadata && result.json_metadata != '') { 497 | var tags = JSON.parse(result.json_metadata).tags; 498 | 499 | if (tags && tags.length > 0) { 500 | var tag = tags.find(t => config.blacklist_settings.blacklisted_tags.indexOf(t) >= 0); 501 | 502 | if(tag) { 503 | if(callback) 504 | callback('blacklist_tag'); 505 | 506 | return; 507 | } 508 | } 509 | } 510 | 511 | var created = new Date(result.created + 'Z'); 512 | var time_until_vote = isVoting ? 0 : utils.timeTilFullPower(utils.getVotingPower(account)); 513 | 514 | // Get the list of votes on this post to make sure the bot didn't already vote on it (you'd be surprised how often people double-submit!) 515 | var votes = result.active_votes.filter(function(vote) { return vote.voter == account.name; }); 516 | 517 | if (votes.length > 0 || ((new Date() - created) >= (config.max_post_age * 60 * 60 * 1000) && !isVoting)) { 518 | // This post is already voted on by this bot or the post is too old to be voted on 519 | if(callback) 520 | callback(((votes.length > 0) ? 'already_voted' : 'max_age')); 521 | 522 | return; 523 | } 524 | 525 | // Check if this post has been flagged by any flag signal accounts 526 | if(config.blacklist_settings.flag_signal_accounts) { 527 | var flags = result.active_votes.filter(function(v) { return v.percent < 0 && config.blacklist_settings.flag_signal_accounts.indexOf(v.voter) >= 0; }); 528 | 529 | if(flags.length > 0) { 530 | if(callback) 531 | callback('flag_signal_account'); 532 | 533 | return; 534 | } 535 | } 536 | 537 | // Check if this post is below the minimum post age 538 | if(config.min_post_age && config.min_post_age > 0 && (new Date() - created + (time_until_vote * 1000)) < (config.min_post_age * 60 * 1000)) { 539 | if(callback) 540 | callback('min_age'); 541 | 542 | return; 543 | } 544 | 545 | // Post is good! 546 | if(callback) 547 | callback(); 548 | } else { 549 | // Invalid memo 550 | if(callback) 551 | callback('invalid_post_url'); 552 | 553 | return; 554 | } 555 | }, function(err) { 556 | logError('Error loading post: ' + permlink + ', Error: ' + err); 557 | 558 | // Try again on error 559 | if(retries < 2) 560 | setTimeout(function() { validatePost(author, permlink, isVoting, callback, retries + 1); }, 3000); 561 | else { 562 | utils.log('============= Validate post failed three times for: ' + permlink + ' ==============='); 563 | 564 | if(callback) 565 | callback('invalid_post_url'); 566 | 567 | return; 568 | } 569 | }); 570 | } 571 | 572 | function checkPost(memo, amount, currency, sender, retries) { 573 | var affiliate = null; 574 | 575 | // Check if this bid is through an affiliate service 576 | if(config.affiliates && config.affiliates.length > 0) { 577 | for(var i = 0; i < config.affiliates.length; i++) { 578 | var cur = config.affiliates[i]; 579 | 580 | if(memo.startsWith(cur.name)) { 581 | memo = memo.split(' ')[1]; 582 | affiliate = cur; 583 | break; 584 | } 585 | } 586 | } 587 | 588 | // Parse the author and permlink from the memo URL 589 | var permLink = memo.substr(memo.lastIndexOf('/') + 1); 590 | var site = memo.substring(memo.indexOf('://')+3,memo.indexOf('/', memo.indexOf('://')+3)); 591 | switch(site) { 592 | case 'd.tube': 593 | var author = memo.substring(memo.indexOf("/v/")+3,memo.lastIndexOf('/')); 594 | break; 595 | case 'dmania.lol': 596 | var author = memo.substring(memo.indexOf("/post/")+6,memo.lastIndexOf('/')); 597 | break; 598 | default: 599 | var author = memo.substring(memo.lastIndexOf('@') + 1, memo.lastIndexOf('/')); 600 | } 601 | 602 | if (author == '' || permLink == '') { 603 | refund(sender, amount, currency, 'invalid_post_url'); 604 | return; 605 | } 606 | 607 | // Make sure the author isn't on the blacklist! 608 | if(whitelist.indexOf(author) < 0 && (blacklist.indexOf(author) >= 0 || blacklist.indexOf(sender) >= 0)) 609 | { 610 | handleBlacklist(author, sender, amount, currency); 611 | return; 612 | } 613 | 614 | // If this bot is whitelist-only then make sure the sender is on the whitelist 615 | if(config.blacklist_settings.whitelist_only && whitelist.indexOf(sender) < 0) { 616 | refund(sender, amount, currency, 'whitelist_only'); 617 | return; 618 | } 619 | 620 | // Check if this author has gone over the max bids per author per round 621 | if(config.max_per_author_per_round && config.max_per_author_per_round > 0) { 622 | if(outstanding_bids.filter(b => b.author == author).length >= config.max_per_author_per_round) 623 | { 624 | refund(sender, amount, currency, 'bids_per_round'); 625 | return; 626 | } 627 | } 628 | 629 | var push_to_next_round = false; 630 | checkGlobalBlacklist(author, sender, function(onBlacklist) { 631 | if(onBlacklist) { 632 | handleBlacklist(author, sender, amount, currency); 633 | return; 634 | } 635 | 636 | validatePost(author, permLink, false, function(error) { 637 | if(error && error != 'min_age') { 638 | refund(sender, amount, currency, error); 639 | return; 640 | } 641 | 642 | // Check if the round is full 643 | if(checkRoundFillLimit(outstanding_bids, amount, currency)) { 644 | if(checkRoundFillLimit(next_round, amount, currency)) { 645 | refund(sender, amount, currency, 'next_round_full'); 646 | return; 647 | } else { 648 | push_to_next_round = true; 649 | refund(sender, 0.001, currency, 'round_full'); 650 | } 651 | } 652 | 653 | // Add the bid to the current round or the next round if the current one is full or the post is too new 654 | var round = (push_to_next_round || error == 'min_age') ? next_round : outstanding_bids; 655 | 656 | // Check if there is already a bid for this post in the current round 657 | var existing_bid = round.find(bid => bid.url == memo); 658 | 659 | if(existing_bid) { 660 | // There is already a bid for this post in the current round 661 | utils.log('Existing Bid Found - New Amount: ' + amount + ', Total Amount: ' + (existing_bid.amount + amount)); 662 | 663 | var new_amount = 0; 664 | 665 | if(existing_bid.currency == currency) { 666 | new_amount = existing_bid.amount + amount; 667 | } else if(existing_bid.currency == 'STEEM') { 668 | new_amount = existing_bid.amount + amount * sbd_price / steem_price; 669 | } else if(existing_bid.currency == 'SBD') { 670 | new_amount = existing_bid.amount + amount * steem_price / sbd_price; 671 | } 672 | 673 | var max_bid = config.max_bid ? parseFloat(config.max_bid) : 9999; 674 | 675 | // Check that the new total doesn't exceed the max bid amount per post 676 | if (new_amount > max_bid) 677 | refund(sender, amount, currency, 'above_max_bid'); 678 | else 679 | existing_bid.amount = new_amount; 680 | } else { 681 | // All good - push to the array of valid bids for this round 682 | utils.log('Valid Bid - Amount: ' + amount + ' ' + currency + ', Url: ' + memo); 683 | round.push({ amount: amount, currency: currency, sender: sender, author: author, permlink: permLink, url: memo }); 684 | 685 | // If this bid is through an affiliate service, send the fee payout 686 | if(affiliate) { 687 | refund(affiliate.beneficiary, amount * (affiliate.fee_pct / 10000), currency, 'affiliate', 0, 'Sender: @' + sender + ', Post: ' + memo); 688 | } 689 | } 690 | 691 | // If a witness_vote transfer memo is set, check if the sender votes for the bot owner as witness and send them a message if not 692 | if (config.transfer_memos['witness_vote'] && config.transfer_memos['witness_vote'] != '') { 693 | checkWitnessVote(sender, sender, currency); 694 | } else if(!push_to_next_round && config.transfer_memos['bid_confirmation'] && config.transfer_memos['bid_confirmation'] != '') { 695 | // Send bid confirmation transfer memo if one is specified 696 | refund(sender, 0.001, currency, 'bid_confirmation', 0); 697 | } 698 | }); 699 | }); 700 | } 701 | 702 | function checkGlobalBlacklist(author, sender, callback) { 703 | if(!config.blacklist_settings || !config.blacklist_settings.global_api_blacklists || !Array.isArray(config.blacklist_settings.global_api_blacklists)) { 704 | callback(null); 705 | return; 706 | } 707 | 708 | request.get('http://blacklist.usesteem.com/user/' + author, function(e, r, data) { 709 | try { 710 | var result = JSON.parse(data); 711 | 712 | if(!result.blacklisted || !Array.isArray(result.blacklisted)) { 713 | callback(null); 714 | return; 715 | } 716 | 717 | if(author != sender) { 718 | checkGlobalBlacklist(sender, sender, callback); 719 | } else 720 | callback(config.blacklist_settings.global_api_blacklists.find(b => result.blacklisted.indexOf(b) >= 0)); 721 | } catch(err) { 722 | utils.log('Error loading global blacklist info for user @' + author + ', Error: ' + err); 723 | callback(null); 724 | } 725 | }); 726 | } 727 | 728 | function handleBlacklist(author, sender, amount, currency) { 729 | utils.log('Invalid Bid - @' + author + ((author != sender) ? ' or @' + sender : '') + ' is on the blacklist!'); 730 | 731 | // Refund the bid only if blacklist_refunds are enabled in config 732 | if (config.blacklist_settings.refund_blacklist) 733 | refund(sender, amount, currency, 'blacklist_refund', 0); 734 | else { 735 | // Otherwise just send a 0.001 transaction with blacklist memo 736 | if (config.transfer_memos['blacklist_no_refund'] && config.transfer_memos['blacklist_no_refund'] != '') 737 | refund(sender, 0.001, currency, 'blacklist_no_refund', 0); 738 | 739 | // If a blacklist donation account is specified then send funds from blacklisted users there 740 | if (config.blacklist_settings.blacklist_donation_account) 741 | refund(config.blacklist_settings.blacklist_donation_account, amount - 0.001, currency, 'blacklist_donation', 0); 742 | } 743 | } 744 | 745 | function handleFlag(sender, amount, currency) { 746 | utils.log('Invalid Bid - This post has been flagged by one or more spam / abuse indicator accounts.'); 747 | 748 | // Refund the bid only if blacklist_refunds are enabled in config 749 | if (config.blacklist_settings.refund_blacklist) 750 | refund(sender, amount, currency, 'flag_refund', 0); 751 | else { 752 | // Otherwise just send a 0.001 transaction with blacklist memo 753 | if (config.transfer_memos['flag_no_refund'] && config.transfer_memos['flag_no_refund'] != '') 754 | refund(sender, 0.001, currency, 'flag_no_refund', 0); 755 | 756 | // If a blacklist donation account is specified then send funds from blacklisted users there 757 | if (config.blacklist_settings.blacklist_donation_account) 758 | refund(config.blacklist_settings.blacklist_donation_account, amount - 0.001, currency, 'blacklist_donation', 0); 759 | } 760 | } 761 | 762 | function checkWitnessVote(sender, voter, currency) { 763 | if(!config.owner_account || config.owner_account == '') 764 | return; 765 | 766 | client.database.getAccounts([voter]).then(function (result) { 767 | if (result) { 768 | if (result[0].proxy && result[0].proxy != '') { 769 | checkWitnessVote(sender, result[0].proxy, currency); 770 | return; 771 | } 772 | 773 | if(result[0].witness_votes.indexOf(config.owner_account) < 0) 774 | refund(sender, 0.001, currency, 'witness_vote', 0); 775 | else if(config.transfer_memos['bid_confirmation'] && config.transfer_memos['bid_confirmation'] != '') { 776 | // Send bid confirmation transfer memo if one is specified 777 | refund(sender, 0.001, currency, 'bid_confirmation', 0); 778 | } 779 | } 780 | }, function(err) { 781 | logError('Error loading sender account to check witness vote: ' + err); 782 | }); 783 | } 784 | 785 | function saveState() { 786 | var state = { 787 | outstanding_bids: outstanding_bids, 788 | last_round: last_round, 789 | next_round: next_round, 790 | transactions: transactions, 791 | last_withdrawal: last_withdrawal, 792 | version: version 793 | }; 794 | 795 | // Save the state of the bot to disk 796 | fs.writeFile('state.json', JSON.stringify(state, null, 2), function (err) { 797 | if (err) 798 | utils.log(err); 799 | }); 800 | } 801 | 802 | function updateVersion(old_version, new_version) { 803 | utils.log('**** Performing Update Steps from version: ' + old_version + ' to version: ' + new_version); 804 | 805 | if(!old_version) { 806 | if(fs.existsSync('delegators.json')) { 807 | fs.rename('delegators.json', 'old-delegators.json', (err) => { 808 | if (err) 809 | utils.log('Error renaming delegators file: ' + err); 810 | else 811 | utils.log('Renamed delegators.json file so it will be reloaded from account history.'); 812 | }); 813 | } 814 | } 815 | } 816 | 817 | function saveDelegators() { 818 | // Save the list of delegators to disk 819 | fs.writeFile('delegators.json', JSON.stringify(delegators), function (err) { 820 | if (err) 821 | utils.log('Error saving delegators to disk: ' + err); 822 | }); 823 | } 824 | 825 | function refund(sender, amount, currency, reason, retries, data) { 826 | if(config.backup_mode) { 827 | utils.log('Backup Mode - not sending refund of ' + amount + ' ' + currency + ' to @' + sender + ' for reason: ' + reason); 828 | return; 829 | } 830 | 831 | if(!retries) 832 | retries = 0; 833 | 834 | // Make sure refunds are enabled and the sender isn't on the no-refund list (for exchanges and things like that). 835 | if (reason != 'forward_payment' && (!config.refunds_enabled || sender == config.account || (config.no_refund && config.no_refund.indexOf(sender) >= 0))) { 836 | utils.log("Invalid bid - " + reason + ' NO REFUND'); 837 | 838 | // If this is a payment from an account on the no_refund list, forward the payment to the post_rewards_withdrawal_account 839 | if(config.no_refund && config.no_refund.indexOf(sender) >= 0 && config.post_rewards_withdrawal_account && config.post_rewards_withdrawal_account != '' && sender != config.post_rewards_withdrawal_account) 840 | refund(config.post_rewards_withdrawal_account, amount, currency, 'forward_payment', 0, sender); 841 | 842 | return; 843 | } 844 | 845 | // Replace variables in the memo text 846 | var memo = config.transfer_memos[reason]; 847 | 848 | if(!memo) 849 | memo = reason; 850 | 851 | memo = memo.replace(/{amount}/g, utils.format(amount, 3) + ' ' + currency); 852 | memo = memo.replace(/{currency}/g, currency); 853 | memo = memo.replace(/{min_bid}/g, config.min_bid); 854 | memo = memo.replace(/{max_bid}/g, config.max_bid); 855 | memo = memo.replace(/{max_bid_whitelist}/g, config.max_bid_whitelist); 856 | memo = memo.replace(/{account}/g, config.account); 857 | memo = memo.replace(/{owner}/g, config.owner_account); 858 | memo = memo.replace(/{min_age}/g, config.min_post_age); 859 | memo = memo.replace(/{sender}/g, sender); 860 | memo = memo.replace(/{tag}/g, data); 861 | 862 | var days = Math.floor(config.max_post_age / 24); 863 | var hours = (config.max_post_age % 24); 864 | memo = memo.replace(/{max_age}/g, days + ' Day(s)' + ((hours > 0) ? ' ' + hours + ' Hour(s)' : '')); 865 | 866 | // Issue the refund. 867 | client.broadcast.transfer({ amount: utils.format(amount, 3) + ' ' + currency, from: config.account, to: sender, memo: memo }, dsteem.PrivateKey.fromString(config.active_key)).then(function(response) { 868 | utils.log('Refund of ' + amount + ' ' + currency + ' sent to @' + sender + ' for reason: ' + reason); 869 | }, function(err) { 870 | logError('Error sending refund to @' + sender + ' for: ' + amount + ' ' + currency + ', Error: ' + err); 871 | 872 | // Try again on error 873 | if(retries < 2) 874 | setTimeout(function() { refund(sender, amount, currency, reason, retries + 1, data) }, (Math.floor(Math.random() * 10) + 3) * 1000); 875 | else 876 | utils.log('============= Refund failed three times for: @' + sender + ' ==============='); 877 | }); 878 | } 879 | 880 | function claimRewards() { 881 | if (!config.auto_claim_rewards || config.backup_mode) 882 | return; 883 | 884 | // Make api call only if you have actual reward 885 | if (parseFloat(account.reward_steem_balance) > 0 || parseFloat(account.reward_sbd_balance) > 0 || parseFloat(account.reward_vesting_balance) > 0) { 886 | var op = ['claim_reward_balance', { account: config.account, reward_sbd: account.reward_sbd_balance, reward_steem: account.reward_steem_balance, reward_vests: account.reward_vesting_balance }]; 887 | client.broadcast.sendOperations([op], dsteem.PrivateKey.fromString(config.posting_key)).then(function(result) { 888 | if (result) { 889 | if(config.detailed_logging) { 890 | var rewards_message = "$$$ ==> Rewards Claim"; 891 | if (parseFloat(account.reward_sbd_balance) > 0) { rewards_message = rewards_message + ' SBD: ' + parseFloat(account.reward_sbd_balance); } 892 | if (parseFloat(account.reward_steem_balance) > 0) { rewards_message = rewards_message + ' STEEM: ' + parseFloat(account.reward_steem_balance); } 893 | if (parseFloat(account.reward_vesting_balance) > 0) { rewards_message = rewards_message + ' VESTS: ' + parseFloat(account.reward_vesting_balance); } 894 | 895 | utils.log(rewards_message); 896 | } 897 | 898 | // If there are liquid SBD rewards, withdraw them to the specified account 899 | if(parseFloat(account.reward_sbd_balance) > 0 && config.post_rewards_withdrawal_account && config.post_rewards_withdrawal_account != '') { 900 | 901 | // Send liquid post rewards to the specified account 902 | client.broadcast.transfer({ amount: account.reward_sbd_balance, from: config.account, to: config.post_rewards_withdrawal_account, memo: 'Liquid Post Rewards Withdrawal' }, dsteem.PrivateKey.fromString(config.active_key)).then(function(response) { 903 | utils.log('$$$ Auto withdrawal - liquid post rewards: ' + account.reward_sbd_balance + ' sent to @' + config.post_rewards_withdrawal_account); 904 | }, function(err) { utils.log('Error transfering liquid SBD post rewards: ' + err); }); 905 | } 906 | 907 | // If there are liquid STEEM rewards, withdraw them to the specified account 908 | if(parseFloat(account.reward_steem_balance) > 0 && config.post_rewards_withdrawal_account && config.post_rewards_withdrawal_account != '') { 909 | 910 | // Send liquid post rewards to the specified account 911 | client.broadcast.transfer({ amount: account.reward_steem_balance, from: config.account, to: config.post_rewards_withdrawal_account, memo: 'Liquid Post Rewards Withdrawal' }, dsteem.PrivateKey.fromString(config.active_key)).then(function(response) { 912 | utils.log('$$$ Auto withdrawal - liquid post rewards: ' + account.reward_steem_balance + ' sent to @' + config.post_rewards_withdrawal_account); 913 | }, function(err) { utils.log('Error transfering liquid STEEM post rewards: ' + err); }); 914 | } 915 | } 916 | }, function(err) { utils.log('Error claiming rewards...will try again next time.'); }); 917 | } 918 | } 919 | 920 | function checkAutoWithdraw() { 921 | // Check if auto-withdraw is active 922 | if (!config.auto_withdrawal.active) 923 | return; 924 | 925 | // If it's past the withdrawal time and we haven't made a withdrawal today, then process the withdrawal 926 | if (new Date(new Date().toDateString()) > new Date(last_withdrawal) && new Date().getHours() >= config.auto_withdrawal.execute_time) { 927 | processWithdrawals(); 928 | } 929 | } 930 | 931 | function processWithdrawals() { 932 | if(config.backup_mode) 933 | return; 934 | 935 | var has_sbd = config.currencies_accepted.indexOf('SBD') >= 0 && parseFloat(account.sbd_balance) > 0; 936 | var has_steem = config.currencies_accepted.indexOf('STEEM') >= 0 && parseFloat(account.balance) > 0; 937 | 938 | if (has_sbd || has_steem) { 939 | 940 | // Save the date of the last withdrawal 941 | last_withdrawal = new Date().toDateString(); 942 | 943 | var total_stake = config.auto_withdrawal.accounts.reduce(function (total, info) { return total + info.stake; }, 0); 944 | 945 | var withdrawals = []; 946 | 947 | for(var i = 0; i < config.auto_withdrawal.accounts.length; i++) { 948 | var withdrawal_account = config.auto_withdrawal.accounts[i]; 949 | 950 | // If this is the special $delegators account, split it between all delegators to the bot 951 | if(withdrawal_account.name == '$delegators') { 952 | // Check if/where we should send payout for SP in the bot account directly 953 | if(withdrawal_account.overrides) { 954 | var bot_override = withdrawal_account.overrides.find(o => o.name == config.account); 955 | 956 | if(bot_override && bot_override.beneficiary) { 957 | var bot_delegator = delegators.find(d => d.delegator == config.account); 958 | 959 | // Calculate the amount of SP in the bot account and add/update it in the list of delegators 960 | var bot_vesting_shares = (parseFloat(account.vesting_shares) - parseFloat(account.delegated_vesting_shares)).toFixed(6) + ' VESTS'; 961 | 962 | if(bot_delegator) 963 | bot_delegator.vesting_shares = bot_vesting_shares; 964 | else 965 | delegators.push({ delegator: config.account, vesting_shares: bot_vesting_shares }); 966 | } 967 | } 968 | 969 | // Get the total amount delegated by all delegators 970 | var total_vests = delegators.reduce(function (total, v) { return total + parseFloat(v.vesting_shares); }, 0); 971 | 972 | // Send the withdrawal to each delegator based on their delegation amount 973 | for(var j = 0; j < delegators.length; j++) { 974 | var delegator = delegators[j]; 975 | var to_account = delegator.delegator; 976 | 977 | // Check if this delegator has an override and if so send the payment to the beneficiary instead 978 | if(withdrawal_account.overrides) { 979 | var override = withdrawal_account.overrides.find(o => o.name == to_account); 980 | 981 | if(override && override.beneficiary) 982 | to_account = override.beneficiary; 983 | } 984 | 985 | if(has_sbd) { 986 | // Check if there is already an SBD withdrawal to this account 987 | var withdrawal = withdrawals.find(w => w.to == to_account && w.currency == 'SBD'); 988 | 989 | if(withdrawal) { 990 | withdrawal.amount += parseFloat(account.sbd_balance) * (withdrawal_account.stake / total_stake) * (parseFloat(delegator.vesting_shares) / total_vests) - 0.001; 991 | } else { 992 | withdrawals.push({ 993 | to: to_account, 994 | currency: 'SBD', 995 | amount: parseFloat(account.sbd_balance) * (withdrawal_account.stake / total_stake) * (parseFloat(delegator.vesting_shares) / total_vests) - 0.001 996 | }); 997 | } 998 | } 999 | 1000 | if(has_steem) { 1001 | // Check if there is already a STEEM withdrawal to this account 1002 | var withdrawal = withdrawals.find(w => w.to == to_account && w.currency == 'STEEM'); 1003 | 1004 | if(withdrawal) { 1005 | withdrawal.amount += parseFloat(account.balance) * (withdrawal_account.stake / total_stake) * (parseFloat(delegator.vesting_shares) / total_vests) - 0.001; 1006 | } else { 1007 | withdrawals.push({ 1008 | to: to_account, 1009 | currency: 'STEEM', 1010 | amount: parseFloat(account.balance) * (withdrawal_account.stake / total_stake) * (parseFloat(delegator.vesting_shares) / total_vests) - 0.001 1011 | }); 1012 | } 1013 | } 1014 | } 1015 | } else { 1016 | if(has_sbd) { 1017 | // Check if there is already an SBD withdrawal to this account 1018 | var withdrawal = withdrawals.find(w => w.to == withdrawal_account.name && w.currency == 'SBD'); 1019 | 1020 | if(withdrawal) { 1021 | withdrawal.amount += parseFloat(account.sbd_balance) * withdrawal_account.stake / total_stake - 0.001; 1022 | } else { 1023 | withdrawals.push({ 1024 | to: withdrawal_account.name, 1025 | currency: 'SBD', 1026 | amount: parseFloat(account.sbd_balance) * withdrawal_account.stake / total_stake - 0.001 1027 | }); 1028 | } 1029 | } 1030 | 1031 | if(has_steem) { 1032 | // Check if there is already a STEEM withdrawal to this account 1033 | var withdrawal = withdrawals.find(w => w.to == withdrawal_account.name && w.currency == 'STEEM'); 1034 | 1035 | if(withdrawal) { 1036 | withdrawal.amount += parseFloat(account.balance) * withdrawal_account.stake / total_stake - 0.001; 1037 | } else { 1038 | withdrawals.push({ 1039 | to: withdrawal_account.name, 1040 | currency: 'STEEM', 1041 | amount: parseFloat(account.balance) * withdrawal_account.stake / total_stake - 0.001 1042 | }); 1043 | } 1044 | } 1045 | } 1046 | } 1047 | 1048 | // Check if the memo should be encrypted 1049 | var encrypt = (config.auto_withdrawal.memo.startsWith('#') && config.memo_key && config.memo_key != ''); 1050 | 1051 | if(encrypt) { 1052 | // Get list of unique withdrawal account names 1053 | var account_names = withdrawals.map(w => w.to).filter((v, i, s) => s.indexOf(v) === i); 1054 | 1055 | // Load account info to get memo keys for encryption 1056 | client.database.getAccounts(account_names).then(function (result) { 1057 | if (result) { 1058 | for(var i = 0; i < result.length; i++) { 1059 | var withdrawal_account = result[i]; 1060 | var matches = withdrawals.filter(w => w.to == withdrawal_account.name); 1061 | 1062 | for(var j = 0; j < matches.length; j++) { 1063 | matches[j].memo_key = withdrawal_account.memo_key; 1064 | } 1065 | } 1066 | 1067 | sendWithdrawals(withdrawals); 1068 | } 1069 | }, function(err) { 1070 | logError('Error loading withdrawal accounts: ' + err); 1071 | }); 1072 | } else 1073 | sendWithdrawals(withdrawals); 1074 | } 1075 | 1076 | updateDelegations(); 1077 | } 1078 | 1079 | function updateDelegations() { 1080 | var updates = delegators.filter(d => parseFloat(d.new_vesting_shares) >= 0); 1081 | 1082 | for (var i = 0; i < updates.length; i++) { 1083 | var delegator = updates[i]; 1084 | 1085 | delegator.vesting_shares = delegator.new_vesting_shares; 1086 | delegator.new_vesting_shares = null; 1087 | } 1088 | 1089 | saveDelegators(); 1090 | } 1091 | 1092 | function sendWithdrawals(withdrawals) { 1093 | // Send out withdrawal transactions one at a time 1094 | sendWithdrawal(withdrawals.pop(), 0, function() { 1095 | // If there are more withdrawals, send the next one. 1096 | if (withdrawals.length > 0) 1097 | sendWithdrawals(withdrawals); 1098 | else 1099 | utils.log('========== Withdrawals Complete! =========='); 1100 | }); 1101 | } 1102 | 1103 | function sendWithdrawal(withdrawal, retries, callback) { 1104 | if(parseFloat(utils.format(withdrawal.amount, 3)) <= 0) { 1105 | if(callback) 1106 | callback(); 1107 | 1108 | return; 1109 | } 1110 | 1111 | var formatted_amount = utils.format(withdrawal.amount, 3).replace(/,/g, '') + ' ' + withdrawal.currency; 1112 | var memo = config.auto_withdrawal.memo.replace(/\{balance\}/g, formatted_amount); 1113 | 1114 | // Encrypt memo 1115 | if (memo.startsWith('#') && config.memo_key && config.memo_key != '') 1116 | memo = steem.memo.encode(config.memo_key, withdrawal.memo_key, memo); 1117 | 1118 | // Send the withdrawal amount to the specified account 1119 | client.broadcast.transfer({ amount: formatted_amount, from: config.account, to: withdrawal.to, memo: memo }, dsteem.PrivateKey.fromString(config.active_key)).then(function(response) { 1120 | utils.log('$$$ Auto withdrawal: ' + formatted_amount + ' sent to @' + withdrawal.to); 1121 | 1122 | if(callback) 1123 | callback(); 1124 | }, function(err) { 1125 | logError('Error sending withdrawal transaction to: ' + withdrawal.to + ', Error: ' + err); 1126 | 1127 | // Try again once if there is an error 1128 | if(retries < 1) 1129 | setTimeout(function() { sendWithdrawal(withdrawal, retries + 1, callback); }, 3000); 1130 | else { 1131 | utils.log('============= Withdrawal failed two times to: ' + withdrawal.to + ' for: ' + formatted_amount + ' ==============='); 1132 | 1133 | if(callback) 1134 | callback(); 1135 | } 1136 | }); 1137 | } 1138 | 1139 | function loadPrices() { 1140 | if(config.price_source == 'coinmarketcap') { 1141 | // Load the price feed data 1142 | request.get('https://api.coinmarketcap.com/v1/ticker/steem/', function (e, r, data) { 1143 | try { 1144 | steem_price = parseFloat(JSON.parse(data)[0].price_usd); 1145 | 1146 | utils.log("Loaded STEEM price: " + steem_price); 1147 | } catch (err) { 1148 | utils.log('Error loading STEEM price: ' + err); 1149 | } 1150 | }); 1151 | 1152 | // Load the price feed data 1153 | request.get('https://api.coinmarketcap.com/v1/ticker/steem-dollars/', function (e, r, data) { 1154 | try { 1155 | sbd_price = parseFloat(JSON.parse(data)[0].price_usd); 1156 | 1157 | utils.log("Loaded SBD price: " + sbd_price); 1158 | } catch (err) { 1159 | utils.log('Error loading SBD price: ' + err); 1160 | } 1161 | }); 1162 | } else if (config.price_source && config.price_source.startsWith('http')) { 1163 | request.get(config.price_source, function (e, r, data) { 1164 | try { 1165 | sbd_price = parseFloat(JSON.parse(data).sbd_price); 1166 | steem_price = parseFloat(JSON.parse(data).steem_price); 1167 | 1168 | utils.log("Loaded STEEM price: " + steem_price); 1169 | utils.log("Loaded SBD price: " + sbd_price); 1170 | } catch (err) { 1171 | utils.log('Error loading STEEM/SBD prices: ' + err); 1172 | } 1173 | }); 1174 | } else { 1175 | // Load STEEM price in BTC from bittrex and convert that to USD using BTC price in coinmarketcap 1176 | request.get('https://api.coinmarketcap.com/v1/ticker/bitcoin/', function (e, r, data) { 1177 | request.get('https://bittrex.com/api/v1.1/public/getticker?market=BTC-STEEM', function (e, r, btc_data) { 1178 | try { 1179 | steem_price = parseFloat(JSON.parse(data)[0].price_usd) * parseFloat(JSON.parse(btc_data).result.Last); 1180 | utils.log('Loaded STEEM Price from Bittrex: ' + steem_price); 1181 | } catch (err) { 1182 | utils.log('Error loading STEEM price from Bittrex: ' + err); 1183 | } 1184 | }); 1185 | 1186 | request.get('https://bittrex.com/api/v1.1/public/getticker?market=BTC-SBD', function (e, r, btc_data) { 1187 | try { 1188 | sbd_price = parseFloat(JSON.parse(data)[0].price_usd) * parseFloat(JSON.parse(btc_data).result.Last); 1189 | utils.log('Loaded SBD Price from Bittrex: ' + sbd_price); 1190 | } catch (err) { 1191 | utils.log('Error loading SBD price from Bittrex: ' + err); 1192 | } 1193 | }); 1194 | }); 1195 | } 1196 | } 1197 | 1198 | function getUsdValue(bid) { return bid.amount * ((bid.currency == 'SBD') ? sbd_price : steem_price); } 1199 | 1200 | function logFailedBid(bid, message) { 1201 | try { 1202 | message = JSON.stringify(message); 1203 | 1204 | if (message.indexOf('assert_exception') >= 0 && message.indexOf('ERR_ASSERTION') >= 0) 1205 | return; 1206 | 1207 | var failed_bids = []; 1208 | 1209 | if(fs.existsSync("failed-bids.json")) 1210 | failed_bids = JSON.parse(fs.readFileSync("failed-bids.json")); 1211 | 1212 | bid.error = message; 1213 | failed_bids.push(bid); 1214 | 1215 | fs.writeFile('failed-bids.json', JSON.stringify(failed_bids), function (err) { 1216 | if (err) 1217 | utils.log('Error saving failed bids to disk: ' + err); 1218 | }); 1219 | } catch (err) { 1220 | utils.log(err); 1221 | } 1222 | } 1223 | 1224 | function loadConfig() { 1225 | config = JSON.parse(fs.readFileSync("config.json")); 1226 | 1227 | // Backwards compatibility for blacklist settings 1228 | if(!config.blacklist_settings) { 1229 | config.blacklist_settings = { 1230 | flag_signal_accounts: config.flag_signal_accounts, 1231 | blacklist_location: config.blacklist_location ? config.blacklist_location : 'blacklist', 1232 | refund_blacklist: config.refund_blacklist, 1233 | blacklist_donation_account: config.blacklist_donation_account, 1234 | blacklisted_tags: config.blacklisted_tags 1235 | }; 1236 | } 1237 | 1238 | var newBlacklist = []; 1239 | 1240 | // Load the blacklist 1241 | utils.loadUserList(config.blacklist_settings.blacklist_location, function(list1) { 1242 | var list = []; 1243 | 1244 | if(list1) 1245 | list = list1; 1246 | 1247 | // Load the shared blacklist 1248 | utils.loadUserList(config.blacklist_settings.shared_blacklist_location, function(list2) { 1249 | if(list2) 1250 | list = list.concat(list2.filter(i => list.indexOf(i) < 0)); 1251 | 1252 | if(list1 || list2) 1253 | blacklist = list; 1254 | }); 1255 | }); 1256 | 1257 | // Load the whitelist 1258 | utils.loadUserList(config.blacklist_settings.whitelist_location, function(list) { 1259 | if(list) 1260 | whitelist = list; 1261 | }); 1262 | } 1263 | 1264 | function failover() { 1265 | if(config.rpc_nodes && config.rpc_nodes.length > 1) { 1266 | // Give it a minute after the failover to account for more errors coming in from the original node 1267 | setTimeout(function() { error_count = 0; }, 60 * 1000); 1268 | 1269 | var cur_node_index = config.rpc_nodes.indexOf(rpc_node) + 1; 1270 | 1271 | if(cur_node_index == config.rpc_nodes.length) 1272 | cur_node_index = 0; 1273 | 1274 | rpc_node = config.rpc_nodes[cur_node_index]; 1275 | 1276 | client = new dsteem.Client(rpc_node); 1277 | utils.log(''); 1278 | utils.log('***********************************************'); 1279 | utils.log('Failing over to: ' + rpc_node); 1280 | utils.log('***********************************************'); 1281 | utils.log(''); 1282 | } 1283 | } 1284 | 1285 | var error_count = 0; 1286 | function logError(message) { 1287 | // Don't count assert exceptions for node failover 1288 | if (message.indexOf('assert_exception') < 0 && message.indexOf('ERR_ASSERTION') < 0) 1289 | error_count++; 1290 | 1291 | utils.log('Error Count: ' + error_count + ', Current node: ' + rpc_node); 1292 | utils.log(message); 1293 | } 1294 | 1295 | // Check if 10+ errors have happened in a 3-minute period and fail over to next rpc node 1296 | function checkErrors() { 1297 | if(error_count >= 10) 1298 | failover(); 1299 | 1300 | // Reset the error counter 1301 | error_count = 0; 1302 | } 1303 | setInterval(checkErrors, 3 * 60 * 1000); 1304 | --------------------------------------------------------------------------------