From d7da59324df9e803b9b9456bb0896f108d3876d8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 30 May 2026 15:36:35 +0000 Subject: [PATCH] Update Salvium wallet RPC support --- .gitignore | 5 +- carrot_test.php | 52 +++++++++++ index.html | 0 migrate_addresses.php | 93 ++++++++++++++++++ src/salvium_tipbot_commands.php | 151 ++++++++++++++++++++++++++++++ src/salvium_tipbot_common.php | 3 +- src/salvium_tipbot_db.php | 36 +++++++ src/salvium_tipbot_wallet.php | 161 +++++++++++++++++++++++++++++++- 8 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 carrot_test.php create mode 100644 index.html create mode 100644 migrate_addresses.php diff --git a/.gitignore b/.gitignore index d32adca..56589f3 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -// .gitignore -// config.php +# Local configuration and secrets config.php +.private/ + *.log .DS_Store *.swp diff --git a/carrot_test.php b/carrot_test.php new file mode 100644 index 0000000..bc52b21 --- /dev/null +++ b/carrot_test.php @@ -0,0 +1,52 @@ +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"; +} +?> diff --git a/index.html b/index.html new file mode 100644 index 0000000..e69de29 diff --git a/migrate_addresses.php b/migrate_addresses.php new file mode 100644 index 0000000..7f8ca11 --- /dev/null +++ b/migrate_addresses.php @@ -0,0 +1,93 @@ +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"; + +?> diff --git a/src/salvium_tipbot_commands.php b/src/salvium_tipbot_commands.php index dbfe8c5..0bba508 100644 --- a/src/salvium_tipbot_commands.php +++ b/src/salvium_tipbot_commands.php @@ -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
"; diff --git a/src/salvium_tipbot_common.php b/src/salvium_tipbot_common.php index 99ee811..eafddb6 100644 --- a/src/salvium_tipbot_common.php +++ b/src/salvium_tipbot_common.php @@ -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; } ?> diff --git a/src/salvium_tipbot_db.php b/src/salvium_tipbot_db.php index 5a26b1d..fe5a3c0 100755 --- a/src/salvium_tipbot_db.php +++ b/src/salvium_tipbot_db.php @@ -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]); +} + } ?> diff --git a/src/salvium_tipbot_wallet.php b/src/salvium_tipbot_wallet.php index 2b34bbb..38a8571 100755 --- a/src/salvium_tipbot_wallet.php +++ b/src/salvium_tipbot_wallet.php @@ -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; + } + } ?>