Update Salvium wallet RPC support

This commit is contained in:
root
2026-05-30 15:36:35 +00:00
parent 87fdc40b26
commit d7da59324d
8 changed files with 496 additions and 5 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
// .gitignore
// config.php
# Local configuration and secrets
config.php
.private/
*.log
.DS_Store
*.swp
+52
View File
@@ -0,0 +1,52 @@
<?php
// test_address_format.php
// Increase error reporting
error_reporting(E_ALL);
ini_set('display_errors', 1);
// --- Autoload dependencies ---
// This assumes the script is in the root directory of your project.
require_once __DIR__ . '/src/salvium_tipbot_wallet.php';
// --- Load Configuration ---
$configFile = __DIR__ . '/config.php';
if (!file_exists($configFile)) {
die("ERROR: config.php not found. Please copy config.sample.php to config.php and fill in your details.\n");
}
$config = require $configFile;
echo "✅ Config loaded successfully.\n";
// --- Instantiate Wallet ---
try {
$wallet = new Salvium\SalviumWallet(
$config['SALVIUM_RPC_HOST'],
$config['SALVIUM_RPC_PORT'],
$config['SALVIUM_RPC_USERNAME'],
$config['SALVIUM_RPC_PASSWORD']
);
echo "✅ Wallet class instantiated.\n";
} catch (Exception $e) {
die("ERROR: Could not instantiate the wallet class: " . $e->getMessage() . "\n");
}
// --- Call getNewSubaddress ---
echo "️ Calling getNewSubaddress()...\n";
$newAddress = $wallet->getNewSubaddress();
if (!$newAddress) {
die("❌ ERROR: The wallet RPC call failed. Check if your salvium-wallet-rpc is running and accessible.\n");
}
echo "✅ Received address: " . $newAddress . "\n";
// --- Validate the Address Format ---
$expectedPrefix = 'SC1';
if (str_starts_with($newAddress, $expectedPrefix)) {
echo "✅ SUCCESS: The new address starts with the correct 'SC1' prefix.\n";
} else {
echo "❌ FAILURE: The new address does not start with 'SC1'.\n";
echo " - Expected prefix: " . $expectedPrefix . "\n";
echo " - Actual prefix: " . substr($newAddress, 0, 3) . "\n";
}
?>
View File
+93
View File
@@ -0,0 +1,93 @@
<?php
// migrate_addresses.php
// --- Setup & Dependencies ---
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once __DIR__ . '/src/salvium_tipbot_db.php';
require_once __DIR__ . '/src/salvium_tipbot_wallet.php';
use Salvium\SalviumTipBotDB;
use Salvium\SalviumWallet;
// --- Load Configuration ---
$configFile = __DIR__ . '/config.php';
if (!file_exists($configFile)) {
die("❌ ERROR: config.php not found.\n");
}
$config = require $configFile;
// --- Confirmation Prompt ---
echo "*****************************************************************\n";
echo "WARNING: This script will generate and assign a new 'SC1'\n";
echo " address for EVERY user in your database.\n";
echo " This action is irreversible.\n";
echo "*****************************************************************\n";
echo "Are you sure you want to continue? (yes/no): ";
$handle = fopen("php://stdin", "r");
$line = trim(fgets($handle));
fclose($handle);
if (strtolower($line) !== 'yes') {
die("🛑 Migration cancelled by user.\n");
}
echo "\n✅ Confirmation received. Starting migration...\n";
// --- Initialize Classes ---
try {
$db = new SalviumTipBotDB($config);
$wallet = new SalviumWallet(
$config['SALVIUM_RPC_HOST'],
$config['SALVIUM_RPC_PORT'],
$config['SALVIUM_RPC_USERNAME'],
$config['SALVIUM_RPC_PASSWORD']
);
} catch (Exception $e) {
die("❌ ERROR: Failed to initialize classes: " . $e->getMessage() . "\n");
}
// --- Fetch All Users ---
$users = $db->getAllUsers();
if (empty($users)) {
die("️ No users found in the database. Nothing to do.\n");
}
$totalUsers = count($users);
echo "✅ Found {$totalUsers} users to update.\n\n";
// --- Migration Loop ---
$successCount = 0;
foreach ($users as $index => $user) {
$userId = $user['id'];
$oldAddress = $user['salvium_subaddress'];
echo "Updating user #{$userId} (" . ($index + 1) . "/{$totalUsers})... ";
// 1. Generate new address
$newAddress = $wallet->getNewSubaddress();
if (!$newAddress || !str_starts_with($newAddress, 'SC1')) {
echo "❌ FAILED: Could not generate a valid new address from the wallet RPC.\n";
continue; // Skip to the next user
}
// 2. Update the database
if ($db->updateUserSubaddress($userId, $newAddress)) {
echo "✅ SUCCESS: New address assigned.\n";
// Optional: uncomment the line below for detailed logging
// echo " - Old: {$oldAddress}\n - New: {$newAddress}\n";
$successCount++;
} else {
echo "❌ FAILED: Database update failed for user #{$userId}.\n";
}
}
// --- Final Report ---
echo "\n============================================\n";
echo "🎉 Migration Complete!\n";
echo "Successfully updated: {$successCount} / {$totalUsers} users.\n";
echo "============================================\n";
?>
+151
View File
@@ -12,6 +12,8 @@ class SalviumTipBotCommands {
'claim' => ['private'],
'withdraw' => ['private'],
'balance' => ['private'],
'ubc' => ['private'],
'txs' => ['private'],
'tip' => ['private', 'group', 'supergroup'],
];
@@ -78,6 +80,155 @@ class SalviumTipBotCommands {
return $user ? "Your balance: {$user['tip_balance']} SAL" : "No account found. Use /deposit first.";
}
/**
* @param int $accountIndex The account index to query (usually 0 for the primary account).
* @return string
*/
private function cmd_ubc(array $args, array $ctx): string {
$allowedUsers = [1319626133, 1835687993];
if (!in_array($ctx['user_id'], $allowedUsers)) {
// Return nothing or a generic error to keep the command hidden
return "⛔ Unauthorized access.";
}
// 1. Get the subaddress index for this user (Default to 0 if not set)
$subaddrIndex = $user['subaddr_index'] ?? 0;
// 2. Call the RPC
// We request only this specific subaddress to keep the response light
$rpcData = $this->wallet->getBalance(0, [$subaddrIndex]);
// 3. Locate the correct data in the nested array
$targetData = null;
// Check if 'balances' exists (Root level)
if (isset($rpcData['balances']) && is_array($rpcData['balances'])) {
// Loop through assets to find 'SAL1'
foreach ($rpcData['balances'] as $asset) {
if (isset($asset['asset_type']) && $asset['asset_type'] === 'SAL1') {
// Now look for the specific subaddress inside this asset
if (isset($asset['per_subaddress']) && is_array($asset['per_subaddress'])) {
foreach ($asset['per_subaddress'] as $sub) {
if ($sub['address_index'] == $subaddrIndex) {
$targetData = $sub;
break 2; // Break both loops
}
}
}
}
}
}
if (!$targetData) {
return "Error: Could not retrieve wallet data from Daemon.";
}
// 4. Extract and Format Data
// Convert atomic units (integers) to readable strings
$totalBalance = $this->atomicToHuman($targetData['balance']);
$unlockedBalance = $this->atomicToHuman($targetData['unlocked_balance']);
$blocksToUnlock = $targetData['blocks_to_unlock'];
// 5. Build the response string
$output = "Wallet Status (RPC)\n";
$output .= "--------------------------\n";
$output .= "Total: {$totalBalance} SAL\n";
$output .= "Unlocked: {$unlockedBalance} SAL\n";
$output .= "Spendable: {$unlockedBalance} SAL\n";
if ($blocksToUnlock > 0) {
$output .= "Pending: Balance unlocks in {$blocksToUnlock} block(s)";
} else {
$output .= "Status: Fully synced & unlocked";
}
return $output;
}
private function cmd_txs(array $args, array $ctx): string {
$allowedUsers = [1319626133, 1835687993];
if (!in_array($ctx['user_id'], $allowedUsers)) {
return "Unauthorized access.";
}
// 1. Parse Arguments (Using indices 1 and 2 as requested)
// Usage: /cmd 1=in/out 2=pending
$direction = isset($args[1]) && strtolower($args[1]) === 'in' ? 'in' : 'out';
$isPending = isset($args[2]) && strtolower($args[2]) === 'pending';
// 2. Get Data
$currentHeight = $this->wallet->getHeight();
$rpcData = $this->wallet->getTransfersAll($direction, $isPending);
if (empty($rpcData) || !is_array($rpcData)) {
$status = $isPending ? "pending" : "confirmed";
return "No $status '$direction' transactions found.";
}
// 3. Slice and Sort
// Reverse first so the newest transactions are at index 0
// $rpcData = array_reverse($rpcData);
$limit = 10;
$recentTxs = array_slice($rpcData, 0, $limit);
// $recentTxs = array_reverse($recentTxs);
// 4. DATABASE LOOKUP
// Extract TxIDs and get usernames
$txIds = array_column($recentTxs, 'txid');
$txOwners = $this->db->getTxOwners($txIds);
// 5. Build Output
$output = "Last $limit " . ucfirst($direction) . ($isPending ? " (Pending)" : "") . " Transfers\n";
$output .= "---------------------------------\n";
foreach ($recentTxs as $tx) {
$amount = $this->atomicToHuman($tx['amount']);
$hashShort = $tx['txid']; // Full hash as requested
$txHeight = $tx['height'];
// Calculate Confirmations
if ($txHeight == 0) {
$confText = "Pending";
} else {
$confs = ($currentHeight && $currentHeight >= $txHeight)
? ($currentHeight - $txHeight) + 1
: 1;
$confText = "$confs confs";
}
// CHECK: Do we have a username for this TxID?
$userString = "";
if (isset($txOwners[$tx['txid']])) {
$username = $txOwners[$tx['txid']];
$userString = " | User: @$username";
}
// Output format: User string attached to TxID line
$output .= "TxID: $hashShort$userString\n";
$output .= "Amt: $amount SAL\n";
$output .= "Blk: $txHeight ($confText)\n\n";
}
return $output;
}
private function atomicToHuman($atomic): string {
if (!is_numeric($atomic) || $atomic < 0) return "0.00";
// Salvium uses 8 decimal places (10^8)
$decimals = 8;
$divisor = bcpow('10', (string)$decimals);
// Use bcdiv for precision with large numbers, setting the output precision to 8
return bcdiv((string)$atomic, $divisor, $decimals);
}
private function cmd_withdraw(array $args, array $ctx): string {
if (count($args) < 3) return "Usage: /withdraw <address> <amount>";
+2 -1
View File
@@ -44,7 +44,8 @@ function sendGif(int $chatId, string $fileIdOrUrl, ?string $caption = null): voi
function isValidSalviumAddress(string $address): bool {
// Accepts standard and subaddress prefixes for Salvium (e.g. SaLvd, SaLvs)
return preg_match('/^SaLv[a-zA-Z0-9]{95}$/', $address) === 1;
// return preg_match('/^SaLv[a-zA-Z0-9]{95}$/', $address) === 1;
return preg_match('/^SC1[a-zA-Z0-9]{95,96}$/', $address) === 1;
}
?>
+36
View File
@@ -310,6 +310,30 @@ class SalviumTipBotDB {
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Accepts an array of TxIDs and returns an associative array [txid => username]
*/
public function getTxOwners(array $txids): array {
if (empty($txids)) {
return [];
}
// Create the correct number of placeholders (?) for the IN clause
$placeholders = str_repeat('?,', count($txids) - 1) . '?';
// Efficient JOIN query to get usernames for these specific TxIDs
$sql = "SELECT w.txid, u.username
FROM withdrawals w
JOIN users u ON w.user_id = u.id
WHERE w.txid IN ($placeholders)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($txids);
// FETCH_KEY_PAIR returns an array like: ['txid123' => 'user_name', 'txid456' => 'other_user']
return $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
}
public function logMessage(int $chatId, string $chatName, string $username, string $message, string $response): void {
try {
$sql = "INSERT INTO bot_log (chat_id, chat_name, username, message, response) VALUES (:chat_id, :chat_name, :username, :message, :response)";
@@ -326,5 +350,17 @@ class SalviumTipBotDB {
}
}
// Add this function inside the SalviumTipBotDB class in src/salvium_tipbot_db.php
public function getAllUsers(): array {
$stmt = $this->pdo->query("SELECT id, telegram_user_id, salvium_subaddress FROM users ORDER BY id ASC");
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function updateUserSubaddress(int $userId, string $newSubaddress): bool {
$stmt = $this->pdo->prepare("UPDATE users SET salvium_subaddress = ? WHERE id = ?");
return $stmt->execute([$newSubaddress, $userId]);
}
}
?>
+159 -2
View File
@@ -14,7 +14,7 @@ class SalviumWallet {
$this->username = $username;
$this->password = $password;
}
/*
private function _callRpc(string $method, array $params = []): array|false {
$url = "http://{$this->host}:{$this->port}/json_rpc";
$request = json_encode([
@@ -51,18 +51,83 @@ class SalviumWallet {
$decoded = json_decode($response, true);
return $decoded['result'] ?? false;
}
*/
// In src/salvium_tipbot_wallet.php
// REPLACE the existing _callRpc function with this corrected version.
private function _callRpc(string $method, array $params = []): array|false {
$url = "http://{$this->host}:{$this->port}/json_rpc";
$request = json_encode([
'jsonrpc' => '2.0',
'id' => '0',
'method' => $method,
'params' => $params
]);
// Add 'Content-Type' and disable the 'Expect: 100-continue' header.
// The empty 'Expect:' header is the crucial fix for compatibility with some RPC servers.
$headers = [
'Content-Type: application/json',
'Expect:'
];
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $request,
CURLOPT_HTTPHEADER => $headers,
];
if ($this->username && $this->password) {
$options[CURLOPT_USERPWD] = "{$this->username}:{$this->password}";
}
$ch = curl_init();
curl_setopt_array($ch, $options);
$response = curl_exec($ch);
if (curl_errno($ch)) {
error_log('RPC Curl Error: ' . curl_error($ch));
curl_close($ch);
return false;
}
curl_close($ch);
$decoded = json_decode($response, true);
return $decoded['result'] ?? false;
}
public function getWalletBalance(): array|false {
return $this->_callRpc('get_balance');
}
/*
public function getNewSubaddress(int $accountIndex = 0, ?string $label = null): string|false {
$params = ['account_index' => $accountIndex];
if ($label) $params['label'] = $label;
$result = $this->_callRpc('create_address', $params);
return $result['address'] ?? false;
}
*/
// Replace the old getNewSubaddress function with this one.
public function getNewSubaddress(int $accountIndex = 0, ?string $label = null): string|false {
// Step 1: Create the address in the wallet's memory.
$params = ['account_index' => $accountIndex];
if ($label) {
$params['label'] = $label;
}
$result = $this->_callRpc('create_address', $params);
if (empty($result['address'])) {
error_log("Failed to create new subaddress via RPC.");
return false;
}
// Step 2: Immediately save the wallet to commit the new address to disk.
// This is the crucial step that ensures the wallet monitors the new address.
$this->saveWallet();
return $result['address'];
}
public function getAddresses(): array|false {
$result = $this->_callRpc('get_address');
return $result['addresses'] ?? false;
@@ -97,9 +162,73 @@ class SalviumWallet {
'failed' => $failed
];
$result = $this->_callRpc('get_transfers', $params);
// Fix: If pending is requested, Monero often puts it in 'pending' key, not in 'in'/'out'
if ($pending && !empty($result['pending'])) {
return $result['pending'];
}
return $result[$inOrOut] ?? false;
}
public function getTransfersAll(string $inOrOut = 'in', bool $pending = false, bool $failed = false, ?int $accountIndex = null): array|false {
// If specific account requested, just do the standard call
if ($accountIndex !== null) {
return $this->_fetchTransfersForAccount($accountIndex, $inOrOut, $pending, $failed);
}
// Otherwise, we must fetch ALL accounts and merge them
$allAccounts = $this->_callRpc('get_accounts');
if (empty($allAccounts['subaddress_accounts'])) {
return false;
}
$mergedTransfers = [];
foreach ($allAccounts['subaddress_accounts'] as $account) {
$idx = $account['account_index'];
// Get transfers for this specific account
$transfers = $this->_fetchTransfersForAccount($idx, $inOrOut, $pending, $failed);
if ($transfers) {
// Add the account index to each transfer so you know where it came from
foreach ($transfers as &$tx) {
$tx['account_index'] = $idx;
}
// Merge into main list
$mergedTransfers = array_merge($mergedTransfers, $transfers);
}
}
// Sort them by timestamp (descending) so they appear in order
usort($mergedTransfers, function ($a, $b) {
return $b['timestamp'] <=> $a['timestamp'];
});
return $mergedTransfers;
}
// Helper to keep the main logic clean
private function _fetchTransfersForAccount(int $idx, string $inOrOut, bool $pending, bool $failed): array {
$params = [
$inOrOut => true,
'pending' => $pending,
'failed' => $failed,
'account_index' => $idx // <--- The critical fix
];
$result = $this->_callRpc('get_transfers', $params);
// Handle the specific way pending txs are returned
if ($pending && !empty($result['pending'])) {
return $result['pending'];
}
return $result[$inOrOut] ?? [];
}
public function getPayments(string $paymentId): array|false {
$result = $this->_callRpc('get_payments', ['payment_id' => $paymentId]);
return $result['payments'] ?? false;
@@ -109,5 +238,33 @@ class SalviumWallet {
$result = $this->_callRpc('get_height');
return $result['height'] ?? false;
}
// Add this new function inside the SalviumWallet class.
public function saveWallet(): bool {
$result = $this->_callRpc('store');
// The 'store' method returns an empty result on success, so we check for 'false' on failure.
return $result !== false;
}
public function getBalance(int $accountIndex = 0, array $subaddrIndices = []): array|false {
$params = [
'account_index' => $accountIndex,
];
// If specific subaddresses are requested, add them to the params
if (!empty($subaddrIndices)) {
$params['address_indices'] = $subaddrIndices;
}
// Note: Since your transfer function uses 'SAL1', if Salvium stores
// assets separately, you might see them in a 'per_subaddress' array
// or need to pass 'asset_type' => 'SAL1' depending on strictness.
// However, standard RPC usually defaults to the main chain asset.
$result = $this->_callRpc('get_balance', $params);
return $result ?? false;
}
}
?>