222 lines
7.3 KiB
PHP
222 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Chain;
|
|
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
class EthDriver implements ChainDriver
|
|
{
|
|
public function chainId(): string
|
|
{
|
|
return 'eth';
|
|
}
|
|
|
|
public function deriveAddress(string $mnemonic, int $index = 0): string
|
|
{
|
|
$derived = Bip44::derive($mnemonic, $this->path($index));
|
|
|
|
return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
|
}
|
|
|
|
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
|
{
|
|
if (! $this->isValidAddress($to)) {
|
|
throw new RuntimeException('Invalid ETH address');
|
|
}
|
|
|
|
$derived = Bip44::derive($mnemonic, $this->path($index));
|
|
$from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
|
$wei = $this->toWei($amount);
|
|
|
|
return $this->sendLegacy($derived['private_key'], $from, $to, $wei, '0x');
|
|
}
|
|
|
|
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
|
|
{
|
|
if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) {
|
|
throw new RuntimeException('Invalid ETH address');
|
|
}
|
|
|
|
$derived = Bip44::derive($mnemonic, $this->path($index));
|
|
$from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
|
$units = $this->toTokenUnits($amount);
|
|
// transfer(address,uint256) selector = a9059cbb
|
|
$data = '0xa9059cbb'.EthAddress::toWord($to).str_pad(gmp_strval(gmp_init($units, 10), 16), 64, '0', STR_PAD_LEFT);
|
|
|
|
return $this->sendLegacy($derived['private_key'], $from, $contract, '0', $data);
|
|
}
|
|
|
|
public function isValidAddress(string $address): bool
|
|
{
|
|
return EthAddress::isValid($address);
|
|
}
|
|
|
|
public function getNativeBalance(string $address): string
|
|
{
|
|
if (! $this->isValidAddress($address)) {
|
|
throw new RuntimeException('Invalid ETH address');
|
|
}
|
|
|
|
$hex = $this->rpc('eth_getBalance', [EthAddress::normalize($address), 'latest']);
|
|
if (! is_string($hex)) {
|
|
return '0';
|
|
}
|
|
|
|
return $this->fromWei($this->hexToDec($hex));
|
|
}
|
|
|
|
public function getTokenBalance(string $address, string $contract): string
|
|
{
|
|
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
|
|
throw new RuntimeException('Invalid ETH address');
|
|
}
|
|
|
|
// balanceOf(address)
|
|
$data = '0x70a08231'.EthAddress::toWord($address);
|
|
$hex = $this->rpc('eth_call', [[
|
|
'to' => EthAddress::normalize($contract),
|
|
'data' => $data,
|
|
], 'latest']);
|
|
if (! is_string($hex) || $hex === '' || $hex === '0x') {
|
|
return '0';
|
|
}
|
|
|
|
return $this->fromTokenUnits($this->hexToDec($hex));
|
|
}
|
|
|
|
private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string
|
|
{
|
|
$chainId = (int) config('coruna.eth.chain_id', 1);
|
|
$nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']);
|
|
$gasPriceHex = $this->rpc('eth_gasPrice', []);
|
|
if (! is_string($nonceHex) || ! is_string($gasPriceHex)) {
|
|
throw new RuntimeException('Failed to fetch nonce/gasPrice');
|
|
}
|
|
|
|
$gasLimit = trim((string) config('coruna.eth.gas_limit', ''));
|
|
if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) {
|
|
$gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000';
|
|
}
|
|
$tx = [
|
|
'nonce' => $this->hexToDec($nonceHex),
|
|
'gasPrice' => $this->hexToDec($gasPriceHex),
|
|
'gas' => $gasLimit,
|
|
'to' => EthAddress::normalize($to),
|
|
'value' => $valueWei,
|
|
'data' => $data === '' ? '0x' : $data,
|
|
];
|
|
|
|
$raw = EthSigner::signLegacy($privateKey, $tx, $chainId);
|
|
$txid = $this->rpc('eth_sendRawTransaction', [$raw]);
|
|
if (! is_string($txid) || $txid === '') {
|
|
throw new RuntimeException('eth_sendRawTransaction failed');
|
|
}
|
|
|
|
return $txid;
|
|
}
|
|
|
|
private function path(int $index): string
|
|
{
|
|
return "m/44'/60'/0'/0/{$index}";
|
|
}
|
|
|
|
private function toWei(string $amount): string
|
|
{
|
|
if (! preg_match('/^\d+(\.\d{1,18})?$/', $amount)) {
|
|
throw new RuntimeException('Invalid ETH amount');
|
|
}
|
|
[$whole, $frac] = array_pad(explode('.', $amount, 2), 2, '');
|
|
$frac = str_pad(substr($frac, 0, 18), 18, '0', STR_PAD_RIGHT);
|
|
$wei = ltrim($whole.$frac, '0');
|
|
$wei = $wei === '' ? '0' : $wei;
|
|
if (bccomp($wei, '0') <= 0) {
|
|
throw new RuntimeException('Amount must be positive');
|
|
}
|
|
|
|
return $wei;
|
|
}
|
|
|
|
private function fromWei(string $wei): string
|
|
{
|
|
if (! preg_match('/^\d+$/', $wei)) {
|
|
$wei = '0';
|
|
}
|
|
$human = bcdiv($wei, '1000000000000000000', 18);
|
|
$human = rtrim(rtrim($human, '0'), '.');
|
|
|
|
return $human === '' ? '0' : $human;
|
|
}
|
|
|
|
private function toTokenUnits(string $amount): string
|
|
{
|
|
$decimals = (int) config('coruna.eth.usdt_decimals', 6);
|
|
if (! preg_match('/^\d+(\.\d{1,'.max(1, $decimals).'})?$/', $amount)) {
|
|
throw new RuntimeException('Invalid token amount');
|
|
}
|
|
$factor = bcpow('10', (string) $decimals, 0);
|
|
$units = bcmul($amount, $factor, 0);
|
|
if (bccomp($units, '0') <= 0) {
|
|
throw new RuntimeException('Amount must be positive');
|
|
}
|
|
|
|
return $units;
|
|
}
|
|
|
|
private function fromTokenUnits(string $units): string
|
|
{
|
|
$decimals = (int) config('coruna.eth.usdt_decimals', 6);
|
|
if (! preg_match('/^\d+$/', $units)) {
|
|
$units = '0';
|
|
}
|
|
$factor = bcpow('10', (string) $decimals, 0);
|
|
$human = bcdiv($units, $factor, $decimals);
|
|
$human = rtrim(rtrim($human, '0'), '.');
|
|
|
|
return $human === '' ? '0' : $human;
|
|
}
|
|
|
|
private function hexToDec(string $hex): string
|
|
{
|
|
$hex = strtolower($hex);
|
|
if (str_starts_with($hex, '0x')) {
|
|
$hex = substr($hex, 2);
|
|
}
|
|
if ($hex === '' || $hex === '0') {
|
|
return '0';
|
|
}
|
|
|
|
return gmp_strval(gmp_init($hex, 16), 10);
|
|
}
|
|
|
|
private function rpc(string $method, array $params): mixed
|
|
{
|
|
$url = rtrim((string) config('coruna.eth.rpc_url', 'https://ethereum.publicnode.com'), '/');
|
|
$resp = $this->http()->post($url, [
|
|
'jsonrpc' => '2.0',
|
|
'id' => 1,
|
|
'method' => $method,
|
|
'params' => $params,
|
|
]);
|
|
if (! $resp->successful()) {
|
|
throw new RuntimeException('ETH RPC HTTP '.$resp->status());
|
|
}
|
|
$json = $resp->json();
|
|
if (! is_array($json)) {
|
|
throw new RuntimeException('Invalid ETH RPC response');
|
|
}
|
|
if (isset($json['error'])) {
|
|
$msg = $json['error']['message'] ?? json_encode($json['error']);
|
|
throw new RuntimeException('ETH RPC: '.(is_string($msg) ? $msg : 'error'));
|
|
}
|
|
|
|
return $json['result'] ?? null;
|
|
}
|
|
|
|
private function http(): PendingRequest
|
|
{
|
|
return Http::timeout(30)->acceptJson()->asJson();
|
|
}
|
|
}
|