|
|
@@ -1,351 +0,0 @@
|
|
|
-<?php
|
|
|
-
|
|
|
-namespace App\Module\Blockchain\Services;
|
|
|
-
|
|
|
-use App\Module\Blockchain\Contracts\BlockchainServiceInterface;
|
|
|
-use App\Module\Blockchain\Dto\TransactionStatus;
|
|
|
-use App\Module\Blockchain\Dto\TransactionResult;
|
|
|
-use App\Module\Transaction\Enums\ACCOUNT_TYPE;
|
|
|
-use Illuminate\Support\Facades\Http;
|
|
|
-use JsonMapper;
|
|
|
-use JsonMapper_Exception;
|
|
|
-use Illuminate\Support\Facades\Cache;
|
|
|
-use Illuminate\Support\Facades\RateLimiter;
|
|
|
-use InvalidArgumentException;
|
|
|
-
|
|
|
-class BscScanService implements BlockchainServiceInterface
|
|
|
-{
|
|
|
-
|
|
|
- protected string $apiUrl;
|
|
|
- protected string $apiKey;
|
|
|
- protected int $cacheTime = 300; // 5分钟缓存
|
|
|
- protected int $retryTimes;
|
|
|
- protected int $retryDelay;
|
|
|
- protected int $timeout;
|
|
|
- protected int $rateLimit;
|
|
|
- protected bool $isTest;
|
|
|
- protected bool $saveResponse = false; // 是否保存API响应到JSON文件
|
|
|
-
|
|
|
- /**
|
|
|
- * 构造函数
|
|
|
- *
|
|
|
- * @param bool $isTest 是否使用测试网络
|
|
|
- */
|
|
|
- public function __construct(bool $isTest = false)
|
|
|
- {
|
|
|
- $this->isTest = $isTest;
|
|
|
- $configPath = $isTest ? 'blockchain.bscscan.api.testnet' : 'blockchain.bscscan.api.mainnet';
|
|
|
- $this->apiUrl = config("$configPath.url");
|
|
|
- $this->apiKey = config("$configPath.key");
|
|
|
- $this->retryTimes = config("$configPath.retry.times", 3);
|
|
|
- $this->retryDelay = config("$configPath.retry.delay", 1000);
|
|
|
- $this->timeout = config("$configPath.timeout", 30);
|
|
|
- $this->rateLimit = config("$configPath.rate_limit", 5);
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 发送API请求
|
|
|
- *
|
|
|
- * @param string $action API动作
|
|
|
- * @param array $params 请求参数
|
|
|
- * @param string $module API模块
|
|
|
- * @return array API响应数据
|
|
|
- * @throws \Exception API请求失败时抛出异常
|
|
|
- */
|
|
|
- protected function makeRequest(string $action, array $params = [], string $module = 'account'): array
|
|
|
- {
|
|
|
- // 检查 API 调用频率
|
|
|
- if (!$this->checkRateLimit()) {
|
|
|
- throw new \Exception('API rate limit exceeded');
|
|
|
- }
|
|
|
-
|
|
|
- $params = array_merge([
|
|
|
- 'module' => $module,
|
|
|
- 'action' => $action,
|
|
|
- 'apikey' => $this->apiKey
|
|
|
- ], $params);
|
|
|
-
|
|
|
- $attempt = 1;
|
|
|
- $lastException = null;
|
|
|
-
|
|
|
- while ($attempt <= $this->retryTimes) {
|
|
|
- try {
|
|
|
- $response = Http::timeout($this->timeout)
|
|
|
- ->get($this->apiUrl, $params);
|
|
|
-
|
|
|
- if ($response->successful()) {
|
|
|
- $data = $response->json();
|
|
|
-
|
|
|
- // 保存API响应到JSON文件
|
|
|
- if ($this->saveResponse) {
|
|
|
- $this->saveApiResponse($module, $action, $data);
|
|
|
- }
|
|
|
-
|
|
|
- return $data;
|
|
|
- }
|
|
|
-
|
|
|
- throw new \Exception('Invalid response from BSCScan API');
|
|
|
- } catch (\Exception $e) {
|
|
|
- $lastException = $e;
|
|
|
- if ($attempt < $this->retryTimes) {
|
|
|
- usleep($this->retryDelay * 1000); // 转换为微秒
|
|
|
- }
|
|
|
- $attempt++;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- throw new \Exception("Failed after {$this->retryTimes} attempts: " . $lastException->getMessage());
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 保存API响应到JSON文件
|
|
|
- * @param string $module 模块名
|
|
|
- * @param string $action 操作名
|
|
|
- * @param array $data 响应数据
|
|
|
- */
|
|
|
- protected function saveApiResponse(string $module, string $action, array $data): void
|
|
|
- {
|
|
|
- $dir = dirname(__DIR__) . '/Json';
|
|
|
- if (!is_dir($dir)) {
|
|
|
- mkdir($dir, 0755, true);
|
|
|
- }
|
|
|
-
|
|
|
- $filename = sprintf('%s/%s_%s.json',
|
|
|
- $dir,
|
|
|
- $module,
|
|
|
- $action
|
|
|
- );
|
|
|
-
|
|
|
- file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT));
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 设置是否保存API响应
|
|
|
- * @param bool $enable 是否启用
|
|
|
- * @return $this
|
|
|
- */
|
|
|
- public function enableResponseSaving(bool $enable = true): self
|
|
|
- {
|
|
|
- $this->saveResponse = $enable;
|
|
|
- return $this;
|
|
|
- }
|
|
|
-
|
|
|
- protected function checkRateLimit(): bool
|
|
|
- {
|
|
|
- return RateLimiter::attempt(
|
|
|
- 'bscscan_api',
|
|
|
- $this->rateLimit,
|
|
|
- function () {
|
|
|
- return true;
|
|
|
- },
|
|
|
- 60 // 1分钟
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 获取账户余额
|
|
|
- *
|
|
|
- * @param string $address 钱包地址
|
|
|
- * @param ACCOUNT_TYPE $tokenType 代币类型
|
|
|
- * @return float 余额数值
|
|
|
- * @throws InvalidArgumentException 当合约地址不存在时抛出
|
|
|
- */
|
|
|
- public function getBalance(string $address, ACCOUNT_TYPE $tokenType): float
|
|
|
- {
|
|
|
- $cacheKey = "balance_{$address}_{$tokenType->value}";
|
|
|
-
|
|
|
- return Cache::remember($cacheKey, $this->cacheTime, function () use ($address, $tokenType) {
|
|
|
- if ($tokenType === ACCOUNT_TYPE::BNB) {
|
|
|
- return $this->getBnbBalance($address);
|
|
|
- }
|
|
|
-
|
|
|
- $contractAddress = $this->getContractAddress($tokenType);
|
|
|
- if (!$contractAddress) {
|
|
|
- throw new InvalidArgumentException("Contract address not found for token type: {$tokenType->value}");
|
|
|
- }
|
|
|
-
|
|
|
- return $this->getTokenBalance($address, $contractAddress, $this->getTokenDecimals($tokenType));
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- protected function getContractAddress(ACCOUNT_TYPE $tokenType): ?string
|
|
|
- {
|
|
|
- return match ($tokenType) {
|
|
|
- ACCOUNT_TYPE::USDT => config('blockchain.bscscan.contracts.usdt.address'),
|
|
|
- ACCOUNT_TYPE::URAUS => config('blockchain.bscscan.contracts.uraus.address'),
|
|
|
- default => null
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- protected function getTokenDecimals(ACCOUNT_TYPE $tokenType): int
|
|
|
- {
|
|
|
- return match ($tokenType) {
|
|
|
- ACCOUNT_TYPE::BNB => 18,
|
|
|
- ACCOUNT_TYPE::USDT => config('blockchain.bscscan.contracts.usdt.decimals', 18),
|
|
|
- ACCOUNT_TYPE::URAUS => config('blockchain.bscscan.contracts.uraus.decimals', 18),
|
|
|
- default => 18
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- public function isValidAddress(string $address): bool
|
|
|
- {
|
|
|
- return preg_match('/^0x[a-fA-F0-9]{40}$/', $address) === 1;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 获取交易状态
|
|
|
- *
|
|
|
- * @param string $txHash 交易哈希
|
|
|
- * @return array{
|
|
|
- * status: int,
|
|
|
- * blockNumber: ?int,
|
|
|
- * gasUsed: ?string,
|
|
|
- * effectiveGasPrice: ?string
|
|
|
- * }
|
|
|
- * @throws \Exception 获取失败时抛出异常
|
|
|
- */
|
|
|
- public function getTransactionStatus(string $txHash):TransactionStatus
|
|
|
- {
|
|
|
- $data = $this->makeRequest('eth_getTransactionByHash', [
|
|
|
- 'txhash' => $txHash
|
|
|
- ], 'proxy');
|
|
|
-
|
|
|
- if (!isset($data['result'])) {
|
|
|
- throw new \Exception('Failed to get transaction status');
|
|
|
- }
|
|
|
-
|
|
|
- $result = new TransactionResult(
|
|
|
- $data['result']['blockNumber'] ? 1 : 0,
|
|
|
- $data['result']['blockNumber'] ? hexdec($data['result']['blockNumber']) : null,
|
|
|
- $data['result']['gasUsed'] ?? null,
|
|
|
- $data['result']['effectiveGasPrice'] ?? null
|
|
|
- );
|
|
|
-
|
|
|
- return $result->toArray();
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 获取交易收据
|
|
|
- *
|
|
|
- * @param string $txHash 交易哈希
|
|
|
- * @return array 交易收据信息
|
|
|
- * @throws \Exception 获取失败时抛出异常
|
|
|
- */
|
|
|
- public function getTransactionReceipt(string $txHash): array
|
|
|
- {
|
|
|
- $data = $this->makeRequest('eth_getTransactionReceipt', [
|
|
|
- 'txhash' => $txHash
|
|
|
- ], 'proxy');
|
|
|
-
|
|
|
-
|
|
|
- if (!isset($data['result'])) {
|
|
|
- throw new \Exception('Failed to get transaction receipt');
|
|
|
- }
|
|
|
-
|
|
|
- return $data['result'];
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 估算交易手续费
|
|
|
- *
|
|
|
- * @param string $from 发送方地址
|
|
|
- * @param string $to 接收方地址
|
|
|
- * @param ACCOUNT_TYPE $tokenType 代币类型
|
|
|
- * @param float $amount 转账金额
|
|
|
- * @return float 预估的gas费用(以BNB为单位)
|
|
|
- * @throws \Exception 估算失败时抛出异常
|
|
|
- */
|
|
|
- public function estimateGasFee(string $from, string $to, ACCOUNT_TYPE $tokenType, float $amount): float
|
|
|
- {
|
|
|
- // 获取当前 gas 价格
|
|
|
- $response = Http::get($this->apiUrl, [
|
|
|
- 'module' => 'proxy',
|
|
|
- 'action' => 'eth_gasPrice',
|
|
|
- 'apikey' => $this->apiKey
|
|
|
- ]);
|
|
|
-
|
|
|
- $data = $response->json();
|
|
|
- if (!isset($data['result'])) {
|
|
|
- throw new \Exception('Failed to get gas price');
|
|
|
- }
|
|
|
-
|
|
|
- $gasPrice = hexdec($data['result']);
|
|
|
- $gasLimit = $tokenType === ACCOUNT_TYPE::BNB ? 21000 : 65000; // BNB转账固定21000,代币约65000
|
|
|
-
|
|
|
- return ($gasPrice * $gasLimit) / 1e18; // 转换为BNB单位
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 获取交易历史记录
|
|
|
- *
|
|
|
- * @param string $address 钱包地址
|
|
|
- * @param ACCOUNT_TYPE $tokenType 代币类型
|
|
|
- * @param int $page 页码
|
|
|
- * @param int $limit 每页数量
|
|
|
- * @return array 交易历史记录
|
|
|
- * @throws InvalidArgumentException 当合约地址不存在时抛出
|
|
|
- */
|
|
|
- public function getTransactionHistory(string $address, ACCOUNT_TYPE $tokenType, int $page = 1, int $limit = 10): array
|
|
|
- {
|
|
|
- if ($tokenType === ACCOUNT_TYPE::BNB) {
|
|
|
- return $this->getNormalTransactions($address, $page, $limit);
|
|
|
- }
|
|
|
-
|
|
|
- return $this->getTokenTransactions($address, $tokenType, $page, $limit);
|
|
|
- }
|
|
|
-
|
|
|
- protected function getBnbBalance(string $address): float
|
|
|
- {
|
|
|
- $data = $this->makeRequest('balance', [
|
|
|
- 'address' => $address
|
|
|
- ], 'account');
|
|
|
-
|
|
|
- return bcdiv($data['result'], '1000000000000000000', 18);
|
|
|
- }
|
|
|
-
|
|
|
- protected function getTokenBalance(string $address, string $contractAddress, int $decimals): float
|
|
|
- {
|
|
|
- $data = $this->makeRequest('tokenbalance', [
|
|
|
- 'contractaddress' => $contractAddress,
|
|
|
- 'address' => $address,
|
|
|
- 'tag' => 'latest'
|
|
|
- ], 'account');
|
|
|
-
|
|
|
- return bcdiv($data['result'], bcpow('10', (string)$decimals), $decimals);
|
|
|
- }
|
|
|
-
|
|
|
- protected function getNormalTransactions(string $address, int $page, int $limit): array
|
|
|
- {
|
|
|
- $data = $this->makeRequest('txlist', [
|
|
|
- 'address' => $address,
|
|
|
- 'startblock' => 0,
|
|
|
- 'endblock' => 99999999,
|
|
|
- 'page' => $page,
|
|
|
- 'offset' => $limit,
|
|
|
- 'sort' => 'desc'
|
|
|
- ], 'account');
|
|
|
-
|
|
|
- return $data['result'];
|
|
|
- }
|
|
|
-
|
|
|
- protected function getTokenTransactions(string $address, ACCOUNT_TYPE $tokenType, int $page, int $limit): array
|
|
|
- {
|
|
|
- $contractAddress = $this->getContractAddress($tokenType);
|
|
|
- if (!$contractAddress) {
|
|
|
- throw new InvalidArgumentException("Contract address not found for token type: {$tokenType->value}");
|
|
|
- }
|
|
|
-
|
|
|
- $data = $this->makeRequest('tokentx', [
|
|
|
- 'address' => $address,
|
|
|
- 'contractaddress' => $contractAddress,
|
|
|
- 'startblock' => 0,
|
|
|
- 'endblock' => 99999999,
|
|
|
- 'page' => $page,
|
|
|
- 'offset' => $limit,
|
|
|
- 'sort' => 'desc'
|
|
|
- ], 'account');
|
|
|
-
|
|
|
- return $data['result'];
|
|
|
- }
|
|
|
-
|
|
|
-}
|