| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- <?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'];
- }
- }
|