Commit 1cc3cedd authored by xiphon's avatar xiphon

Abstraction layer to support other coins, Github and Zcoin support

parent 8cdb68ee
......@@ -38,10 +38,10 @@ PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
MONERO_USERNAME=
MONERO_PASSWORD=
COIN=monero
RPC_URL=http://127.0.0.1:28080/json_rpc
RPC_USER=
RPC_PASSWORD=
WALLET_ADDRESS=
GITLAB_URL=https://repo.getmonero.org/api/v4/projects/54
REPOSITORY_URL=https://repo.getmonero.org/api/v4/projects/54
GITHUB_ACCESS_TOKEN=
[submodule "storage/app/proposals"]
path = storage/app/proposals
url = https://repo.getmonero.org/monero-project/ccs-proposals.git
branch = master
<?php
namespace App\Coin;
use Illuminate\Console\Command;
use Monero\WalletCommon;
interface Coin
{
public function newWallet() : WalletCommon;
public function onNotifyGetTransactions(Command $command, WalletCommon $wallet);
public function subaddrIndex($addressDetails, $project);
}
class CoinAuto
{
public static function newCoin() : Coin
{
$coin = env('COIN', 'monero');
switch ($coin) {
case 'monero':
return new CoinMonero();
case 'zcoin':
return new CoinZcoin();
default:
throw new \Exception('Unsupported COIN ' . $coin);
}
}
}
<?php
namespace App\Coin;
use Illuminate\Console\Command;
use Monero\WalletCommon;
use Monero\WalletOld;
class CoinMonero implements Coin
{
public function newWallet() : WalletCommon
{
return new WalletOld();
}
public function onNotifyGetTransactions(Command $command, WalletCommon $wallet)
{
$min_height = $command->argument('height') ?? Deposit::max('block_received');
return $wallet->scanIncomingTransfers(max($min_height, 50) - 50);
}
public function subaddrIndex($addressDetails, $project)
{
return $addressDetails['subaddr_index'];
}
}
<?php
namespace App\Coin;
use App\Deposit;
use App\Project;
use Illuminate\Console\Command;
use Monero\WalletCommon;
use Monero\WalletZcoin;
class CoinZcoin implements Coin
{
public function newWallet() : WalletCommon
{
return new WalletZcoin();
}
public function onNotifyGetTransactions(Command $command, WalletCommon $wallet)
{
$skip_txes = Deposit::whereNotNull('tx_id')->where('confirmations', '>', 10)->count();
return $wallet->scanIncomingTransfers($skip_txes)->each(function ($tx) {
$project = Project::where('address', $tx->address)->first();
if ($project) {
$tx->subaddr_index = $project->subaddr_index;
}
});
}
public function subaddrIndex($addressDetails, $project)
{
return $project->id;
}
}
......@@ -2,10 +2,10 @@
namespace App\Console\Commands;
use App\Coin\CoinAuto;
use App\Project;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Monero\WalletOld;
class GenerateAddresses extends Command
{
......@@ -40,17 +40,21 @@ class GenerateAddresses extends Command
*/
public function handle()
{
$coin = CoinAuto::newCoin();
$wallet = $coin->newWallet();
$projects = Project::whereNotNull('filename')->whereNull('address')->where('state', 'FUNDING-REQUIRED')->get();
$wallet = new WalletOld();
foreach ($projects as $project) {
$addressDetails = $wallet->getPaymentAddress();
$project->address_uri = $wallet->createQrCodeString($addressDetails['address']);
$project->address = $addressDetails['address'];
$project->subaddr_index = $addressDetails['subaddr_index'];
$project->subaddr_index = $coin->subaddrIndex($addressDetails, $project);
Storage::disk('public')->put("/img/qrcodes/{$project->subaddr_index}.png", $project->generateQrcode());
$project->qr_code = "img/qrcodes/{$project->subaddr_index}.png";
$project->raised_amount = 0;
$project->save();
$this->info('Project: ' . $project->filename . ', address: ' . $project->address);
}
}
......
<?php
namespace App\Console\Commands;
class moneroNotify extends walletNotify
{
private $coin;
private $wallet;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monero:notify
{height? : Scan wallet transactions starting from the specified height}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Checks the monero blockchain for transactions';
}
......@@ -3,7 +3,8 @@
namespace App\Console\Commands;
use App\Project;
use GitLab\Connection;
use App\Repository\State;
use App\Repository\Connection;
use GuzzleHttp\Client;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
......@@ -40,20 +41,20 @@ class ProcessProposals extends Command
$result = [];
$connection = new Connection(new Client());
$merged = $connection->mergeRequests('merged');
$merged = $connection->mergeRequests(State::Merged);
foreach ($merged as $request) {
$newFiles = $connection->getNewFiles($request->iid);
if (sizeof($newFiles) != 1) {
$newFiles = $connection->getNewFiles($request);
if ($newFiles->count() != 1) {
continue;
}
$filename = $newFiles[0];
$filename = $newFiles->first();
if (!preg_match('/.+\.md$/', $filename)) {
continue;
}
if (basename($filename) != $filename) {
continue;
}
$result[$filename] = $request->web_url;
$result[$filename] = $request->url();
}
return $result;
......
......@@ -3,8 +3,9 @@
namespace App\Console\Commands;
use App\Project;
use App\Repository\State;
use App\Repository\Connection;
use Illuminate\Console\Command;
use GitLab\Connection;
use GuzzleHttp\Client;
use stdClass;
use Symfony\Component\Yaml\Yaml;
......@@ -76,38 +77,38 @@ class UpdateSiteProposals extends Command
$ideas = [];
$connection = new Connection(new Client());
$mergeRequests = $connection->mergeRequests('opened');
$mergeRequests = $connection->mergeRequests(State::Opened);
foreach ($mergeRequests as $mergeRequest) {
$newFiles = $connection->getNewFiles($mergeRequest->iid);
if (sizeof($newFiles) != 1) {
$this->error ("Skipping MR #$mergeRequest->id '$mergeRequest->title': contains multiple files");
$newFiles = $connection->getNewFiles($mergeRequest);
if ($newFiles->count() != 1) {
$this->error ("Skipping MR #{$mergeRequest->id()} '{$mergeRequest->title()}': contains multiple files");
continue;
}
$filename = $newFiles[0];
$filename = $newFiles->first();
if (!preg_match('/.+\.md$/', $filename)) {
$this->error("Skipping MR #$mergeRequest->id '$mergeRequest->title': doesn't contain any .md file");
$this->error("Skipping MR #{$mergeRequest->id()} '{$mergeRequest->title()}': doesn't contain any .md file");
continue;
}
if (basename($filename) != $filename) {
$this->error("Skipping MR #$mergeRequest->id '$mergeRequest->title': $filename must be in the root folder");
$this->error("Skipping MR #{$mergeRequest->id()} '{$mergeRequest->title()}': $filename must be in the root folder");
continue;
}
if (in_array($filename, $ideas)) {
$this->error("Skipping MR #$mergeRequest->id '$mergeRequest->title': duplicated $filename, another MR #$ideas[$filename]->id");
$this->error("Skipping MR #{$mergeRequest->id()} '{$mergeRequest->title()}': duplicated $filename, another MR #$ideas[$filename]->id");
continue;
}
$project = Project::where('filename', $filename)->first();
if ($project && $this->proposalFileExists($filename)) {
$this->error("Skipping MR #$mergeRequest->id '$mergeRequest->title': already have a project $filename");
$this->error("Skipping MR #{$mergeRequest->id()} '{$mergeRequest->title()}': already have a project $filename");
continue;
}
$this->info("Idea MR #$mergeRequest->id '$mergeRequest->title': $filename");
$this->info("Idea MR #{$mergeRequest->id()} '{$mergeRequest->title()}': $filename");
$prop = new stdClass();
$prop->name = htmlspecialchars(trim($mergeRequest->title), ENT_QUOTES);
$prop->{'gitlab-url'} = htmlspecialchars($mergeRequest->web_url, ENT_QUOTES);
$prop->author = htmlspecialchars($mergeRequest->author->username, ENT_QUOTES);
$prop->date = date('F j, Y', strtotime($mergeRequest->created_at));
$prop->name = htmlspecialchars(trim($mergeRequest->title()), ENT_QUOTES);
$prop->{'gitlab-url'} = htmlspecialchars($mergeRequest->url(), ENT_QUOTES);
$prop->author = htmlspecialchars($mergeRequest->author(), ENT_QUOTES);
$prop->date = date('F j, Y', $mergeRequest->created_at());
$responseProposals[] = $prop;
}
......@@ -119,7 +120,8 @@ class UpdateSiteProposals extends Command
{
$prop = new stdClass();
$prop->name = $proposal->title;
$prop->{'donate-url'} = url("projects/{$proposal->subaddr_index}/donate");
$prop->{'donate-address'} = $proposal->address;
$prop->{'donate-qr-code'} = $proposal->address_uri ? $proposal->getQrCodeSrcAttribute() : null;
$prop->{'gitlab-url'} = $proposal->gitlab_url;
$prop->{'local-url'} = '/proposals/'. pathinfo($proposal->filename, PATHINFO_FILENAME) . '.html';
$prop->contributions = $proposal->contributions;
......
......@@ -2,28 +2,30 @@
namespace App\Console\Commands;
use App\Coin\CoinAuto;
use App\Deposit;
use App\Project;
use Illuminate\Console\Command;
use Monero\Transaction;
use Monero\WalletOld;
class walletNotify extends Command
{
private $coin;
private $wallet;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monero:notify
{height? : Scan wallet transactions starting from the specified height}';
protected $signature = 'wallet:notify';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Checks the monero blockchain for transactions';
protected $description = 'Checks the blockchain for transactions';
/**
* Create a new command instance.
......@@ -33,6 +35,9 @@ class walletNotify extends Command
public function __construct()
{
parent::__construct();
$this->coin = CoinAuto::newCoin();
$this->wallet = $this->coin->newWallet();
}
/**
......@@ -42,18 +47,15 @@ class walletNotify extends Command
*/
public function handle()
{
$wallet = new WalletOld();
$blockheight = $wallet->blockHeight();
$blockheight = $this->wallet->blockHeight();
if ($blockheight < 1) {
$this->error('monero daemon down or wrong port in db ?');
$this->error('failed to fetch blockchain height');
return;
}
$min_height = $this->argument('height') ?? Deposit::max('block_received');
$transactions = $wallet->scanIncomingTransfers(max($min_height, 50) - 50);
$transactions->each(function ($transaction) use ($wallet) {
$transactions = $this->coin->onNotifyGetTransactions($this, $this->wallet);
$transactions->each(function ($transaction) {
$this->processPayment($transaction);
});
......@@ -67,6 +69,9 @@ class walletNotify extends Command
*/
public function processPayment(Transaction $transaction)
{
$amountCoins = $transaction->amount / 10 ** $this->wallet->digitsAfterTheRadixPoint();
$details = 'address: ' . $transaction->address . ' amount: '. $amountCoins . ' txid: '.$transaction->id;
$deposit = Deposit::where('tx_id', $transaction->id)->where('subaddr_index', $transaction->subaddr_index)->first();
if ($deposit) {
if ($deposit->block_received == 0) {
......@@ -76,16 +81,15 @@ class walletNotify extends Command
return null;
}
$this->info('amount: '.$transaction->amount / 1000000000000 .' confirmations:'.$transaction->confirmations.' tx_hash:'.$transaction->id);
$this->info('subaddr_index: '.$transaction->subaddr_index);
$this->createDeposit($transaction);
$project = Project::where('subaddr_index', $transaction->subaddr_index)->first();
if ($project) {
if ($project = Project::where('subaddr_index', $transaction->subaddr_index)->first()) {
// update the project total
$project->raised_amount = $project->raised_amount + $transaction->amount * 1e-12;
$project->raised_amount = $project->raised_amount + $amountCoins;
$project->save();
$this->info('Donation to "' . $project->filename . '" '. $details);
} else {
$this->error('Unrecognized donation, ' . $details);
}
return;
......@@ -122,7 +126,7 @@ class walletNotify extends Command
*/
public function updateConfirmation($blockheight, Deposit $deposit)
{
$diff = $blockheight - $deposit->block_received;
$diff = $blockheight - $deposit->block_received + 1;
$deposit->confirmations = $diff;
$deposit->save();
......
......@@ -46,10 +46,6 @@ class Project extends Model
return $this->hasMany(Deposit::class, 'subaddr_index', 'subaddr_index');
}
public function getAmountReceivedAttribute() {
return $this->deposits->sum('amount') * 1e-12;
}
public function getPercentageFundedAttribute() {
return min(100, round($this->raised_amount / $this->target_amount * 100));
}
......
<?php
namespace App\Repository;
use GuzzleHttp\Client;
class Connection
{
private $repo;
public function __construct(Client $client)
{
$url = env('REPOSITORY_URL') ?? env('GITLAB_URL');
if (parse_url($url, PHP_URL_HOST) == 'github.com') {
$this->repo = new Github($client, $url);
} else {
$this->repo = new Gitlab($client, $url);
}
}
public function mergeRequests($state) {
return $this->repo->mergeRequests($state);
}
public function getNewFiles($merge_request_iid) {
return $this->repo->getNewFiles($merge_request_iid);
}
}
<?php
namespace App\Repository;
use GuzzleHttp\Client;
class PullRequest implements Proposal
{
private $pull_request;
public function __construct($pull_request)
{
$this->pull_request = $pull_request;
}
public function id() : int
{
return $this->pull_request->number;
}
public function url() : string
{
return $this->pull_request->html_url;
}
public function title() : string
{
return $this->pull_request->title;
}
public function author() : string
{
return $this->pull_request->user->login;
}
public function created_at() : int
{
return strtotime($this->pull_request->created_at);
}
}
class Github implements Repository
{
private $client;
private $options = [];
private $owner_repo;
private const stateToString = [ State::Opened => 'open' ,
State::Merged => 'closed'];
public function __construct(Client $client, string $repository_url)
{
$this->client = $client;
$this->owner_repo = parse_url($repository_url, PHP_URL_PATH);
if ($token = env('GITHUB_ACCESS_TOKEN')) {
$this->options = ['headers' => ['Authorization' => 'token ' . $token]];
}
}
private function getUrl($url)
{
return $this->client->request('GET', $url, $this->options);
}
public function mergeRequests($state)
{
$url = 'https://api.github.com/repos' . $this->owner_repo . '/pulls?state=' . Self::stateToString[$state];
$response = $this->getUrl($url);
$result = collect(json_decode($response->getBody()));
if ($state == State::Merged) {
$result = $result->filter(function ($pull_request) {
return $pull_request->merged_at !== null;
});
}
return $result->map(function ($pull_request) {
return new PullRequest($pull_request);
});
}
public function getNewFiles($pull_request)
{
$url = 'https://api.github.com/repos' . $this->owner_repo . '/pulls/' . $pull_request->id() . '/files';
$response = $this->getUrl($url);
return collect(json_decode($response->getBody()))->filter(function ($change) {
return $change->status == 'added';
})->map(function ($change) {
return $change->filename;
});
}
}
<?php
namespace App\Repository;
use GuzzleHttp\Client;
class MergeRequest implements Proposal
{
private $merge_request;
public function __construct($merge_request)
{
$this->merge_request = $merge_request;
}
public function iid() : int
{
return $this->merge_request->iid;
}
public function id() : int
{
return $this->merge_request->id;
}
public function url() : string
{
return $this->merge_request->web_url;
}
public function title() : string
{
return $this->merge_request->title;
}
public function author() : string
{
return $this->merge_request->author->username;
}
public function created_at() : int
{
return strtotime($this->merge_request->created_at);
}
}
class Gitlab implements Repository
{
private $client;
private $base_url;
private const stateToString = [ State::Opened => 'opened',
State::Merged => 'merged'];
public function __construct(Client $client, string $repository_url)
{
$this->client = $client;
$this->base_url = $repository_url;
}
public function mergeRequests($state)
{
$url = $this->base_url . '/merge_requests?scope=all&per_page=50&state=' . Self::stateToString[$state];
$response = $this->client->request('GET', $url);
return collect(json_decode($response->getBody()))->map(function ($merge_request) {
return new MergeRequest($merge_request);
});
}
public function getNewFiles($merge_request)
{
$url = $this->base_url . '/merge_requests/' . $merge_request->iid() . '/changes';
$response = $this->client->request('GET', $url);
return collect(json_decode($response->getBody())->changes)->filter(function ($change) {
return $change->new_file;
})->map(function ($change) {
return $change->new_path;
});
}
}
<?php
namespace App\Repository;
use GuzzleHttp\Client;
interface State
{
const Merged = 0;
const Opened = 1;
const All = 2;
}
interface Proposal
{
public function id() : int;
public function url() : string;
public function title() : string;
public function author() : string;
public function created_at() : int;
}
interface Repository
{
public function __construct(Client $client, string $repository_url);
public function mergeRequests($state);