Files
xmrpay.link/api/verify.php
2026-03-26 13:28:40 +01:00

220 lines
7.0 KiB
PHP

<?php
require_once __DIR__ . '/_helpers.php';
/**
* TX Proof Storage API
* POST: Store verified payment proof for an invoice
* GET: Retrieve payment status for an invoice
*
* Privacy: Only TX hash, amount, and confirmations are stored.
* Payee address is NEVER stored — verification happens client-side only.
*/
header('Content-Type: application/json');
send_security_headers();
$dbFile = __DIR__ . '/../data/proofs.json';
function fetch_transaction_confirmations(string $txHash): ?int {
$nodes = [
'http://node.xmr.rocks:18089',
'http://node.community.rino.io:18081',
'http://node.sethforprivacy.com:18089',
'http://xmr-node.cakewallet.com:18081',
];
foreach ($nodes as $node) {
$ch = curl_init($node . '/gettransactions');
if ($ch === false) {
continue;
}
$body = json_encode((object)['txs_hashes' => [$txHash]]);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
continue;
}
$decoded = json_decode($response, true);
if (!is_array($decoded) || !isset($decoded['txs']) || !is_array($decoded['txs']) || !isset($decoded['txs'][0])) {
continue;
}
$tx = $decoded['txs'][0];
if (!is_array($tx)) {
continue;
}
return intval($tx['confirmations'] ?? 0);
}
return null;
}
// GET: Retrieve proof
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (!check_rate_limit('verify_get', 30, 60)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$code = isset($_GET['code']) && is_string($_GET['code']) ? $_GET['code'] : '';
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
echo json_encode(['verified' => false]);
exit;
}
$rawProofs = file_exists($dbFile) ? file_get_contents($dbFile) : null;
$decodedProofs = is_string($rawProofs) ? json_decode($rawProofs, true) : [];
$proofs = is_array($decodedProofs) ? $decodedProofs : [];
if (isset($proofs[$code])) {
$proofEntry = $proofs[$code];
$proofExpiry = is_array($proofEntry) ? intval($proofEntry['e'] ?? 0) : 0;
// Check if proof has expired (lazy cleanup)
if ($proofExpiry > 0 && time() > $proofExpiry) {
unset($proofs[$code]);
[$fp, $allProofs] = read_json_locked($dbFile);
/** @var array<string, mixed> $allProofs */
if (isset($allProofs[$code])) {
unset($allProofs[$code]);
write_json_locked($fp, $allProofs);
}
echo json_encode(['verified' => false]);
} else {
if (is_array($proofEntry) && ($proofEntry['status'] ?? 'paid') === 'pending' && isset($proofEntry['tx_hash']) && is_string($proofEntry['tx_hash'])) {
$latestConfirmations = fetch_transaction_confirmations($proofEntry['tx_hash']);
if ($latestConfirmations !== null && $latestConfirmations > intval($proofEntry['confirmations'] ?? 0)) {
$proofEntry['confirmations'] = $latestConfirmations;
if ($latestConfirmations >= 10) {
$proofEntry['status'] = 'paid';
}
[$fp, $allProofs] = read_json_locked($dbFile);
/** @var array<string, mixed> $allProofs */
$allProofs[$code] = $proofEntry;
write_json_locked($fp, $allProofs);
}
}
$response = ['verified' => true];
if (is_array($proofEntry)) {
foreach ($proofEntry as $k => $v) {
if (is_string($k)) {
$response[$k] = $v;
}
}
}
echo json_encode($response);
}
} else {
echo json_encode(['verified' => false]);
}
exit;
}
// POST: Store proof
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
verify_origin();
if (!check_rate_limit('verify_post', 10, 3600)) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
$rawInput = file_get_contents('php://input');
$input = is_string($rawInput) ? json_decode($rawInput, true) : null;
if (!is_array($input)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
$code = isset($input['code']) && is_string($input['code']) ? $input['code'] : '';
$txHash = isset($input['tx_hash']) && is_string($input['tx_hash']) ? $input['tx_hash'] : '';
$amount = floatval($input['amount'] ?? 0);
$confirmations = intval($input['confirmations'] ?? 0);
// Validate
if (!preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid code']);
exit;
}
if (!preg_match('/^[0-9a-fA-F]{64}$/', $txHash)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid tx_hash']);
exit;
}
// Verify the short URL code exists (read-only, no lock needed here)
$urlsFile = __DIR__ . '/../data/urls.json';
if (!file_exists($urlsFile)) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
exit;
}
$rawUrls = file_get_contents($urlsFile);
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
$urls = is_array($decodedUrls) ? $decodedUrls : [];
if (!isset($urls[$code])) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
exit;
}
// Store proof with atomic lock
[$fp, $proofs] = read_json_locked($dbFile);
if (!is_array($proofs)) {
$proofs = [];
}
$status = ($input['status'] ?? 'paid') === 'pending' ? 'pending' : 'paid';
// Allow overwriting a pending proof with more confirmations or a final paid status
if (isset($proofs[$code])) {
$existing = $proofs[$code];
$canOverwrite = ($existing['status'] ?? 'paid') === 'pending'
&& ($status === 'paid' || $confirmations > ($existing['confirmations'] ?? 0));
if (!$canOverwrite) {
flock($fp, LOCK_UN);
fclose($fp);
echo json_encode(['ok' => true]);
exit;
}
}
$proofs[$code] = [
'tx_hash' => strtolower($txHash),
'amount' => $amount,
'confirmations' => $confirmations,
'status' => $status,
'verified_at' => time()
];
// Copy expiry timestamp from URL if it exists
if (isset($urls[$code]) && is_array($urls[$code]) && isset($urls[$code]['e']) && $urls[$code]['e'] > 0) {
$proofs[$code]['e'] = $urls[$code]['e'];
}
write_json_locked($fp, $proofs);
echo json_encode(['ok' => true]);