diff --git a/README.md b/README.md
index 14526524..885c5573 100644
--- a/README.md
+++ b/README.md
@@ -68,11 +68,11 @@ Features
The following feature have been implemented so far:
-* Mobile WebUI **NEW**
+* Mobile WebUI
* Reward Systems
* Propotional
* PPS
- * (Planned) PPLNS
+ * PPLNS **NEW**
* Use of memcache for statistics instead of a cronjob
* Web User accounts
* Re-Captcha protected registration form
diff --git a/cronjobs/pplns_payout.php b/cronjobs/pplns_payout.php
new file mode 100755
index 00000000..295702c9
--- /dev/null
+++ b/cronjobs/pplns_payout.php
@@ -0,0 +1,142 @@
+#!/usr/bin/php
+getAllUnaccounted('ASC');
+if (empty($aAllBlocks)) {
+ verbose("No new unaccounted blocks found\n");
+ exit(0);
+}
+
+// We support some dynamic share targets but fall back to our fixed value
+if ($config['pplns']['shares']['type'] == 'blockavg' && $block->getBlockCount() > 0) {
+ $pplns_target = round($block->getAvgBlockShares($config['pplns']['type']['blockavg']['blockcount']));
+} else {
+ $pplns_target = $config['pplns']['shares']['default'] ;
+}
+
+$count = 0;
+foreach ($aAllBlocks as $iIndex => $aBlock) {
+ if (!$aBlock['accounted']) {
+ $iPreviousShareId = @$aAllBlocks[$iIndex - 1]['share_id'] ? $aAllBlocks[$iIndex - 1]['share_id'] : 0;
+ $iCurrentUpstreamId = $aBlock['share_id'];
+ $iRoundShares = $share->getRoundShares($iPreviousShareId, $aBlock['share_id']);
+ $config['reward_type'] == 'block' ? $dReward = $aBlock['amount'] : $dReward = $config['reward'];
+ $aRoundAccountShares = $share->getSharesForAccounts($iPreviousShareId, $aBlock['share_id']);
+
+ if ($iRoundShares >= $pplns_target) {
+ verbose("Matching or exceeding PPLNS target of $pplns_target\n");
+ $aAccountShares = $share->getSharesForAccounts($aBlock['share_id'] - $pplns_target + 1, $aBlock['share_id']);
+ } else {
+ verbose("Not able to match PPLNS target of $pplns_target\n");
+ // We need to fill up with archived shares
+ // Grab the full current round shares since we didn't match target
+ $aAccountShares = $aRoundAccountShares;
+ // Grab only the most recent shares from Archive that fill the missing shares
+ $aArchiveShares = $share->getArchiveShares($share->getMaxArchiveShareId() - ($pplns_target- $iRoundShares) + 1, $share->getMaxArchiveShareId());
+ // Add archived shares to users current shares, if we have any in archive
+ if (is_array($aArchiveShares)) {
+ foreach($aAccountShares as $key => $aData) {
+ if (array_key_exists($aData['username'], $aArchiveShares)) {
+ $aAccountShares[$key]['valid'] += $aArchiveShares[$aData['username']]['valid'];
+ $aAccountShares[$key]['invalid'] += $aArchiveShares[$aData['username']]['invalid'];
+ }
+ }
+ }
+ }
+ if (empty($aAccountShares)) {
+ verbose("\nNo shares found for this block\n\n");
+ sleep(2);
+ continue;
+ }
+
+ // Table header for account shares
+ verbose("ID\tUsername\tValid\tInvalid\tPercentage\tPayout\t\tDonation\tFee\t\tStatus\n");
+
+ // Loop through all accounts that have found shares for this round
+ foreach ($aAccountShares as $key => $aData) {
+ // Payout based on PPLNS target shares, proportional payout for all users
+ $aData['percentage'] = number_format(round(( 100 / $pplns_target) * $aData['valid'], 8), 8);
+ $aData['payout'] = number_format(round(( $aData['percentage'] / 100 ) * $dReward, 8), 8);
+ // Defaults
+ $aData['fee' ] = 0;
+ $aData['donation'] = 0;
+
+ if ($config['fees'] > 0)
+ $aData['fee'] = number_format(round($config['fees'] / 100 * $aData['payout'], 8), 8);
+ // Calculate donation amount, fees not included
+ $aData['donation'] = number_format(round($user->getDonatePercent($user->getUserId($aData['username'])) / 100 * ( $aData['payout'] - $aData['fee']), 8), 8);
+
+ // Verbose output of this users calculations
+ verbose($aData['id'] . "\t" .
+ $aData['username'] . "\t" .
+ $aData['valid'] . "\t" .
+ $aData['invalid'] . "\t" .
+ $aData['percentage'] . "\t" .
+ $aData['payout'] . "\t" .
+ $aData['donation'] . "\t" .
+ $aData['fee'] . "\t");
+
+ $strStatus = "OK";
+ // Add full round share statistics, not just PPLNS
+ foreach ($aRoundAccountShares as $key => $aRoundData) {
+ if ($aRoundData['username'] == $aData['username'])
+ if (!$statistics->updateShareStatistics($aRoundData, $aBlock['id']))
+ $strStatus = "Stats Failed";
+ }
+ // Add new credit transaction
+ if (!$transaction->addTransaction($aData['id'], $aData['payout'], 'Credit', $aBlock['id']))
+ $strStatus = "Transaction Failed";
+ // Add new fee debit for this block
+ if ($aData['fee'] > 0 && $config['fees'] > 0)
+ if (!$transaction->addTransaction($aData['id'], $aData['fee'], 'Fee', $aBlock['id']))
+ $strStatus = "Fee Failed";
+ // Add new donation debit
+ if ($aData['donation'] > 0)
+ if (!$transaction->addTransaction($aData['id'], $aData['donation'], 'Donation', $aBlock['id']))
+ $strStatus = "Donation Failed";
+ verbose("\t$strStatus\n");
+ }
+
+ // Move counted shares to archive before this blockhash upstream share
+ $share->moveArchive($iCurrentUpstreamId, $aBlock['id'], $iPreviousShareId);
+ // Delete all accounted shares
+ if (!$share->deleteAccountedShares($iCurrentUpstreamId, $iPreviousShareId)) {
+ verbose("\nERROR : Failed to delete accounted shares from $iPreviousShareId to $iCurrentUpstreamId, aborting!\n");
+ exit(1);
+ }
+ // Mark this block as accounted for
+ if (!$block->setAccounted($aBlock['id'])) {
+ verbose("\nERROR : Failed to mark block as accounted! Aborting!\n");
+ }
+
+ verbose("------------------------------------------------------------------------\n\n");
+ }
+}
diff --git a/cronjobs/run-crons.sh b/cronjobs/run-crons.sh
index c9dfd949..b6b241b4 100755
--- a/cronjobs/run-crons.sh
+++ b/cronjobs/run-crons.sh
@@ -16,7 +16,7 @@ PIDFILE='/tmp/mmcfe-ng-cron.pid'
CRONHOME='.'
# List of cruns to execute
-CRONS="findblock.php proportional_payout.php pps_payout.php blockupdate.php auto_payout.php tickerupdate.php notifications.php statistics.php"
+CRONS="findblock.php proportional_payout.php pplns_payout.php pps_payout.php blockupdate.php auto_payout.php tickerupdate.php notifications.php statistics.php"
# Additional arguments to pass to cronjobs
CRONARGS="-v"
diff --git a/public/include/classes/block.class.php b/public/include/classes/block.class.php
index 81d1806a..8f52efd9 100644
--- a/public/include/classes/block.class.php
+++ b/public/include/classes/block.class.php
@@ -79,6 +79,30 @@ class Block {
return false;
}
+ /**
+ * Get total amount of blocks in our table
+ * @param noone
+ * @return data int Count of rows
+ **/
+ public function getBlockCount() {
+ $stmt = $this->mysqli->prepare("SELECT COUNT(id) AS blocks FROM $this->table");
+ if ($this->checkStmt($stmt) && $stmt->execute() && $result = $stmt->get_result())
+ return (int)$result->fetch_object()->blocks;
+ return false;
+ }
+
+ /**
+ * Fetch our average share count for the past N blocks
+ * @param limit int Maximum blocks to check
+ * @return data float Float value of average shares
+ **/
+ public function getAvgBlockShares($limit=10) {
+ $stmt = $this->mysqli->prepare("SELECT AVG(shares) AS average FROM $this->table LIMIT ?");
+ if ($this->checkStmt($stmt) && $stmt->bind_param('i', $limit) && $stmt->execute() && $result = $stmt->get_result())
+ return (float)$result->fetch_object()->average;
+ return false;
+ }
+
/**
* Fetch all unconfirmed blocks from table
* @param confirmations int Required confirmations to consider block confirmed
diff --git a/public/include/classes/share.class.php b/public/include/classes/share.class.php
index 496652f0..ed6078ea 100644
--- a/public/include/classes/share.class.php
+++ b/public/include/classes/share.class.php
@@ -13,9 +13,10 @@ class Share {
// This defines each share
public $rem_host, $username, $our_result, $upstream_result, $reason, $solution, $time;
- public function __construct($debug, $mysqli, $salt) {
+ public function __construct($debug, $mysqli, $user) {
$this->debug = $debug;
$this->mysqli = $mysqli;
+ $this->user = $user;
$this->debug->append("Instantiated Share class", 2);
}
@@ -86,47 +87,67 @@ class Share {
* Fetch all shares grouped by accounts to count share per account
* @param previous_upstream int Previous found share accepted by upstream to limit results
* @param current_upstream int Current upstream accepted share
+ * @param limit int Limit to this amount of shares for PPLNS
* @return data array username, valid and invalid shares from account
**/
public function getSharesForAccounts($previous_upstream=0, $current_upstream) {
- $stmt = $this->mysqli->prepare("SELECT
- a.id,
- validT.account AS username,
- sum(validT.valid) as valid,
- IFNULL(sum(invalidT.invalid),0) as invalid
- FROM
- (
- SELECT DISTINCT
- SUBSTRING_INDEX( `username` , '.', 1 ) as account,
- COUNT(id) AS valid
- FROM $this->table
- WHERE id BETWEEN ? AND ?
- AND our_result = 'Y'
- GROUP BY account
- ) validT
- LEFT JOIN
- (
- SELECT DISTINCT
- SUBSTRING_INDEX( `username` , '.', 1 ) as account,
- COUNT(id) AS invalid
- FROM $this->table
- WHERE id BETWEEN ? AND ?
- AND our_result = 'N'
- GROUP BY account
- ) invalidT
- ON validT.account = invalidT.account
- INNER JOIN accounts a ON a.username = validT.account
- GROUP BY a.username DESC");
- if ($this->checkStmt($stmt)) {
- $stmt->bind_param('iiii', $previous_upstream, $current_upstream, $previous_upstream, $current_upstream);
- $stmt->execute();
- $result = $stmt->get_result();
- $stmt->close();
+ $stmt = $this->mysqli->prepare("
+ SELECT
+ a.id,
+ SUBSTRING_INDEX( s.username , '.', 1 ) as username,
+ SUM(IF(our_result='Y', 1, 0)) AS valid,
+ SUM(IF(our_result='N', 1, 0)) AS invalid
+ FROM $this->table AS s
+ LEFT JOIN " . $this->user->getTableName() . " AS a
+ ON a.username = SUBSTRING_INDEX( s.username , '.', 1 )
+ WHERE s.id BETWEEN ? AND ?
+ GROUP BY username DESC
+ ");
+ if ($this->checkStmt($stmt) && $stmt->bind_param('ii', $previous_upstream, $current_upstream) && $stmt->execute() && $result = $stmt->get_result())
return $result->fetch_all(MYSQLI_ASSOC);
- }
return false;
}
+ /**
+ * Fetch the highest available share ID from archive
+ **/
+ function getMaxArchiveShareId() {
+ $stmt = $this->mysqli->prepare("
+ SELECT MAX(share_id) AS share_id FROM $this->tableArchive
+ ");
+ if ($this->checkStmt($stmt) && $stmt->execute() && $result = $stmt->get_result())
+ return $result->fetch_object()->share_id;
+ return false;
+ }
+
+ /**
+ * We need a certain amount of valid archived shares
+ * param left int Left/lowest share ID
+ * param right int Right/highest share ID
+ * return array data Returns an array with usernames as keys for easy access
+ **/
+ function getArchiveShares($left, $right) {
+ $stmt = $this->mysqli->prepare("
+ SELECT
+ a.id,
+ SUBSTRING_INDEX( s.username , '.', 1 ) as username,
+ SUM(IF(our_result='Y', 1, 0)) AS valid,
+ SUM(IF(our_result='N', 1, 0)) AS invalid
+ FROM $this->tableArchive AS s
+ LEFT JOIN " . $this->user->getTableName() . " AS a
+ ON a.username = SUBSTRING_INDEX( s.username , '.', 1 )
+ WHERE s.id BETWEEN ? AND ?
+ GROUP BY username DESC
+ ");
+ if ($this->checkStmt($stmt) && $stmt->bind_param("ii", $left, $right) && $stmt->execute() && $result = $stmt->get_result()) {
+ $aData = NULL;
+ while ($row = $result->fetch_assoc()) {
+ $aData[$row['username']] = $row;
+ }
+ if (is_array($aData)) return $aData;
+ }
+ return false;
+ }
/**
* Move accounted shares to archive table, this step is optional
* @param previous_upstream int Previous found share accepted by upstream to limit results
@@ -135,10 +156,11 @@ class Share {
* @return bool
**/
public function moveArchive($current_upstream, $block_id, $previous_upstream=0) {
- $archive_stmt = $this->mysqli->prepare("INSERT INTO $this->tableArchive (share_id, username, our_result, upstream_result, block_id, time)
- SELECT id, username, our_result, upstream_result, ?, time
- FROM $this->table
- WHERE id BETWEEN ? AND ?");
+ $archive_stmt = $this->mysqli->prepare("
+ INSERT INTO $this->tableArchive (share_id, username, our_result, upstream_result, block_id, time)
+ SELECT id, username, our_result, upstream_result, ?, time
+ FROM $this->table
+ WHERE id BETWEEN ? AND ?");
if ($this->checkStmt($archive_stmt) && $archive_stmt->bind_param('iii', $block_id, $previous_upstream, $current_upstream) && $archive_stmt->execute()) {
$archive_stmt->close();
return true;
@@ -267,4 +289,4 @@ class Share {
}
}
-$share = new Share($debug, $mysqli, SALT);
+$share = new Share($debug, $mysqli, $user);
diff --git a/public/include/config/global.inc.dist.php b/public/include/config/global.inc.dist.php
index 9390b534..995fc0b2 100644
--- a/public/include/config/global.inc.dist.php
+++ b/public/include/config/global.inc.dist.php
@@ -183,6 +183,30 @@ $config['chaininfo'] = 'http://allchains.info';
// Pool fees applied to users in percent, default: 0 (disabled)
$config['fees'] = 0;
+/**
+ * PPLNS requires some settings to run properly. First we need to define
+ * a default shares count that is applied if we don't have a proper type set.
+ * Different dynamic types can be applied, or you can run a fixed scheme.
+ *
+ * Explanation
+ * default : Default target shares for PPLNS
+ * type : Payout type used in PPLNS
+ * blockcount : Amount of blocks to check for avg shares
+ *
+ * Available Options:
+ * default : amount of shares, integeger
+ * type : blockavg or fixed
+ * blockcount : amount of blocks, any integer
+ *
+ * Defaults:
+ * default = 4000000
+ * type = `blockavg`
+ * blockcount = 10
+ **/
+$config['pplns']['shares']['default'] = 4000000;
+$config['pplns']['shares']['type'] = 'blockavg';
+$config['pplns']['blockavg']['blockcount'] = 10;
+
// Pool target difficulty as set in pushpoold configuration file
// Please also read this for stratum: https://github.com/TheSerapher/php-mmcfe-ng/wiki/FAQ
$config['difficulty'] = 20;
diff --git a/public/include/smarty_globals.inc.php b/public/include/smarty_globals.inc.php
index 147b38e9..fd5083d6 100644
--- a/public/include/smarty_globals.inc.php
+++ b/public/include/smarty_globals.inc.php
@@ -83,6 +83,13 @@ if (@$_SESSION['USERDATA']['id']) {
$aGlobal['userdata']['sharerate'] = $statistics->getUserSharerate($_SESSION['USERDATA']['id']);
switch ($config['payout_system']) {
+ case 'pplns':
+ if ($iAvgBlockShares = round($block->getAvgBlockShares($config['pplns']['type']['blockavg']['blockcount']))) {
+ $aGlobal['pplns']['target'] = $iAvgBlockShares;
+ } else {
+ $aGlobal['pplns']['target'] = $config['pplns']['shares']['default'];
+ }
+ break;
case 'pps':
break;
default:
diff --git a/public/templates/mmcFE/global/sidebar_pplns.tpl b/public/templates/mmcFE/global/sidebar_pplns.tpl
new file mode 100644
index 00000000..deb0c1cc
--- /dev/null
+++ b/public/templates/mmcFE/global/sidebar_pplns.tpl
@@ -0,0 +1,74 @@
+
diff --git a/public/templates/mobile/global/sidebar_pplns.tpl b/public/templates/mobile/global/sidebar_pplns.tpl
new file mode 100644
index 00000000..95dcf93c
--- /dev/null
+++ b/public/templates/mobile/global/sidebar_pplns.tpl
@@ -0,0 +1,63 @@
+
+
+ | PPLNS Target |
+ {$GLOBAL.pplns.target|number_format} |
+
+ | |
+
+ | Your Stats |
+
+
+ | Hashrate |
+ {$GLOBAL.userdata.hashrate|number_format} KH/s |
+
+
+ Unpaid Shares  |
+
+
+ | Your Valid |
+ {$GLOBAL.userdata.shares.valid|number_format} |
+
+
+ | Pool Valid |
+ {$GLOBAL.roundshares.valid|number_format} |
+
+
+ Round Shares  |
+
+
+ | Pool Valid |
+ {$GLOBAL.roundshares.valid|number_format} |
+
+
+ | Pool Invalid |
+ {$GLOBAL.roundshares.invalid|number_format}{if $GLOBAL.roundshares.valid > 0} ({(100 / $GLOBAL.roundshares.valid * $GLOBAL.roundshares.invalid)|number_format:"2"}%){/if} |
+
+
+ | Your Invalid |
+ {$GLOBAL.userdata.shares.invalid|number_format}{if $GLOBAL.roundshares.valid > 0} ({(100 / $GLOBAL.roundshares.valid * $GLOBAL.userdata.shares.invalid)|number_format:"2"}%){/if} |
+
+
+ | {$GLOBAL.config.currency} Round Estimate |
+
+
+ | Block |
+ {$GLOBAL.userdata.est_block|number_format:"3"} |
+
+
+ | Fees |
+ {$GLOBAL.userdata.est_fee|number_format:"3"} |
+
+
+ | Donation |
+ {$GLOBAL.userdata.est_donation|number_format:"3"} |
+
+
+ | Payout |
+ {$GLOBAL.userdata.est_payout|number_format:"3"} |
+
+ | |
+ | {$GLOBAL.config.currency} Account Balance |
+ | Confirmed | {$GLOBAL.userdata.balance.confirmed|default:"0"} |
+ | Unconfirmed | {$GLOBAL.userdata.balance.unconfirmed|default:"0"} |
+