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;
+ }
+
}
?>