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);
|
||||
|
||||
Reference in New Issue
Block a user