feat: Solana chain
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user