BscScanService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. <?php
  2. namespace App\Module\Blockchain\Services;
  3. use App\Module\Blockchain\Contracts\BlockchainServiceInterface;
  4. use App\Module\Blockchain\Dto\TransactionStatus;
  5. use App\Module\Blockchain\Dto\TransactionResult;
  6. use App\Module\Transaction\Enums\ACCOUNT_TYPE;
  7. use Illuminate\Support\Facades\Http;
  8. use JsonMapper;
  9. use JsonMapper_Exception;
  10. use Illuminate\Support\Facades\Cache;
  11. use Illuminate\Support\Facades\RateLimiter;
  12. use InvalidArgumentException;
  13. class BscScanService implements BlockchainServiceInterface
  14. {
  15. protected string $apiUrl;
  16. protected string $apiKey;
  17. protected int $cacheTime = 300; // 5分钟缓存
  18. protected int $retryTimes;
  19. protected int $retryDelay;
  20. protected int $timeout;
  21. protected int $rateLimit;
  22. protected bool $isTest;
  23. protected bool $saveResponse = false; // 是否保存API响应到JSON文件
  24. /**
  25. * 构造函数
  26. *
  27. * @param bool $isTest 是否使用测试网络
  28. */
  29. public function __construct(bool $isTest = false)
  30. {
  31. $this->isTest = $isTest;
  32. $configPath = $isTest ? 'blockchain.bscscan.api.testnet' : 'blockchain.bscscan.api.mainnet';
  33. $this->apiUrl = config("$configPath.url");
  34. $this->apiKey = config("$configPath.key");
  35. $this->retryTimes = config("$configPath.retry.times", 3);
  36. $this->retryDelay = config("$configPath.retry.delay", 1000);
  37. $this->timeout = config("$configPath.timeout", 30);
  38. $this->rateLimit = config("$configPath.rate_limit", 5);
  39. }
  40. /**
  41. * 发送API请求
  42. *
  43. * @param string $action API动作
  44. * @param array $params 请求参数
  45. * @param string $module API模块
  46. * @return array API响应数据
  47. * @throws \Exception API请求失败时抛出异常
  48. */
  49. protected function makeRequest(string $action, array $params = [], string $module = 'account'): array
  50. {
  51. // 检查 API 调用频率
  52. if (!$this->checkRateLimit()) {
  53. throw new \Exception('API rate limit exceeded');
  54. }
  55. $params = array_merge([
  56. 'module' => $module,
  57. 'action' => $action,
  58. 'apikey' => $this->apiKey
  59. ], $params);
  60. $attempt = 1;
  61. $lastException = null;
  62. while ($attempt <= $this->retryTimes) {
  63. try {
  64. $response = Http::timeout($this->timeout)
  65. ->get($this->apiUrl, $params);
  66. if ($response->successful()) {
  67. $data = $response->json();
  68. // 保存API响应到JSON文件
  69. if ($this->saveResponse) {
  70. $this->saveApiResponse($module, $action, $data);
  71. }
  72. return $data;
  73. }
  74. throw new \Exception('Invalid response from BSCScan API');
  75. } catch (\Exception $e) {
  76. $lastException = $e;
  77. if ($attempt < $this->retryTimes) {
  78. usleep($this->retryDelay * 1000); // 转换为微秒
  79. }
  80. $attempt++;
  81. }
  82. }
  83. throw new \Exception("Failed after {$this->retryTimes} attempts: " . $lastException->getMessage());
  84. }
  85. /**
  86. * 保存API响应到JSON文件
  87. * @param string $module 模块名
  88. * @param string $action 操作名
  89. * @param array $data 响应数据
  90. */
  91. protected function saveApiResponse(string $module, string $action, array $data): void
  92. {
  93. $dir = dirname(__DIR__) . '/Json';
  94. if (!is_dir($dir)) {
  95. mkdir($dir, 0755, true);
  96. }
  97. $filename = sprintf('%s/%s_%s.json',
  98. $dir,
  99. $module,
  100. $action
  101. );
  102. file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT));
  103. }
  104. /**
  105. * 设置是否保存API响应
  106. * @param bool $enable 是否启用
  107. * @return $this
  108. */
  109. public function enableResponseSaving(bool $enable = true): self
  110. {
  111. $this->saveResponse = $enable;
  112. return $this;
  113. }
  114. protected function checkRateLimit(): bool
  115. {
  116. return RateLimiter::attempt(
  117. 'bscscan_api',
  118. $this->rateLimit,
  119. function () {
  120. return true;
  121. },
  122. 60 // 1分钟
  123. );
  124. }
  125. /**
  126. * 获取账户余额
  127. *
  128. * @param string $address 钱包地址
  129. * @param ACCOUNT_TYPE $tokenType 代币类型
  130. * @return float 余额数值
  131. * @throws InvalidArgumentException 当合约地址不存在时抛出
  132. */
  133. public function getBalance(string $address, ACCOUNT_TYPE $tokenType): float
  134. {
  135. $cacheKey = "balance_{$address}_{$tokenType->value}";
  136. return Cache::remember($cacheKey, $this->cacheTime, function () use ($address, $tokenType) {
  137. if ($tokenType === ACCOUNT_TYPE::BNB) {
  138. return $this->getBnbBalance($address);
  139. }
  140. $contractAddress = $this->getContractAddress($tokenType);
  141. if (!$contractAddress) {
  142. throw new InvalidArgumentException("Contract address not found for token type: {$tokenType->value}");
  143. }
  144. return $this->getTokenBalance($address, $contractAddress, $this->getTokenDecimals($tokenType));
  145. });
  146. }
  147. protected function getContractAddress(ACCOUNT_TYPE $tokenType): ?string
  148. {
  149. return match ($tokenType) {
  150. ACCOUNT_TYPE::USDT => config('blockchain.bscscan.contracts.usdt.address'),
  151. ACCOUNT_TYPE::URAUS => config('blockchain.bscscan.contracts.uraus.address'),
  152. default => null
  153. };
  154. }
  155. protected function getTokenDecimals(ACCOUNT_TYPE $tokenType): int
  156. {
  157. return match ($tokenType) {
  158. ACCOUNT_TYPE::BNB => 18,
  159. ACCOUNT_TYPE::USDT => config('blockchain.bscscan.contracts.usdt.decimals', 18),
  160. ACCOUNT_TYPE::URAUS => config('blockchain.bscscan.contracts.uraus.decimals', 18),
  161. default => 18
  162. };
  163. }
  164. public function isValidAddress(string $address): bool
  165. {
  166. return preg_match('/^0x[a-fA-F0-9]{40}$/', $address) === 1;
  167. }
  168. /**
  169. * 获取交易状态
  170. *
  171. * @param string $txHash 交易哈希
  172. * @return array{
  173. * status: int,
  174. * blockNumber: ?int,
  175. * gasUsed: ?string,
  176. * effectiveGasPrice: ?string
  177. * }
  178. * @throws \Exception 获取失败时抛出异常
  179. */
  180. public function getTransactionStatus(string $txHash):TransactionStatus
  181. {
  182. $data = $this->makeRequest('eth_getTransactionByHash', [
  183. 'txhash' => $txHash
  184. ], 'proxy');
  185. if (!isset($data['result'])) {
  186. throw new \Exception('Failed to get transaction status');
  187. }
  188. $result = new TransactionResult(
  189. $data['result']['blockNumber'] ? 1 : 0,
  190. $data['result']['blockNumber'] ? hexdec($data['result']['blockNumber']) : null,
  191. $data['result']['gasUsed'] ?? null,
  192. $data['result']['effectiveGasPrice'] ?? null
  193. );
  194. return $result->toArray();
  195. }
  196. /**
  197. * 获取交易收据
  198. *
  199. * @param string $txHash 交易哈希
  200. * @return array 交易收据信息
  201. * @throws \Exception 获取失败时抛出异常
  202. */
  203. public function getTransactionReceipt(string $txHash): array
  204. {
  205. $data = $this->makeRequest('eth_getTransactionReceipt', [
  206. 'txhash' => $txHash
  207. ], 'proxy');
  208. if (!isset($data['result'])) {
  209. throw new \Exception('Failed to get transaction receipt');
  210. }
  211. return $data['result'];
  212. }
  213. /**
  214. * 估算交易手续费
  215. *
  216. * @param string $from 发送方地址
  217. * @param string $to 接收方地址
  218. * @param ACCOUNT_TYPE $tokenType 代币类型
  219. * @param float $amount 转账金额
  220. * @return float 预估的gas费用(以BNB为单位)
  221. * @throws \Exception 估算失败时抛出异常
  222. */
  223. public function estimateGasFee(string $from, string $to, ACCOUNT_TYPE $tokenType, float $amount): float
  224. {
  225. // 获取当前 gas 价格
  226. $response = Http::get($this->apiUrl, [
  227. 'module' => 'proxy',
  228. 'action' => 'eth_gasPrice',
  229. 'apikey' => $this->apiKey
  230. ]);
  231. $data = $response->json();
  232. if (!isset($data['result'])) {
  233. throw new \Exception('Failed to get gas price');
  234. }
  235. $gasPrice = hexdec($data['result']);
  236. $gasLimit = $tokenType === ACCOUNT_TYPE::BNB ? 21000 : 65000; // BNB转账固定21000,代币约65000
  237. return ($gasPrice * $gasLimit) / 1e18; // 转换为BNB单位
  238. }
  239. /**
  240. * 获取交易历史记录
  241. *
  242. * @param string $address 钱包地址
  243. * @param ACCOUNT_TYPE $tokenType 代币类型
  244. * @param int $page 页码
  245. * @param int $limit 每页数量
  246. * @return array 交易历史记录
  247. * @throws InvalidArgumentException 当合约地址不存在时抛出
  248. */
  249. public function getTransactionHistory(string $address, ACCOUNT_TYPE $tokenType, int $page = 1, int $limit = 10): array
  250. {
  251. if ($tokenType === ACCOUNT_TYPE::BNB) {
  252. return $this->getNormalTransactions($address, $page, $limit);
  253. }
  254. return $this->getTokenTransactions($address, $tokenType, $page, $limit);
  255. }
  256. protected function getBnbBalance(string $address): float
  257. {
  258. $data = $this->makeRequest('balance', [
  259. 'address' => $address
  260. ], 'account');
  261. return bcdiv($data['result'], '1000000000000000000', 18);
  262. }
  263. protected function getTokenBalance(string $address, string $contractAddress, int $decimals): float
  264. {
  265. $data = $this->makeRequest('tokenbalance', [
  266. 'contractaddress' => $contractAddress,
  267. 'address' => $address,
  268. 'tag' => 'latest'
  269. ], 'account');
  270. return bcdiv($data['result'], bcpow('10', (string)$decimals), $decimals);
  271. }
  272. protected function getNormalTransactions(string $address, int $page, int $limit): array
  273. {
  274. $data = $this->makeRequest('txlist', [
  275. 'address' => $address,
  276. 'startblock' => 0,
  277. 'endblock' => 99999999,
  278. 'page' => $page,
  279. 'offset' => $limit,
  280. 'sort' => 'desc'
  281. ], 'account');
  282. return $data['result'];
  283. }
  284. protected function getTokenTransactions(string $address, ACCOUNT_TYPE $tokenType, int $page, int $limit): array
  285. {
  286. $contractAddress = $this->getContractAddress($tokenType);
  287. if (!$contractAddress) {
  288. throw new InvalidArgumentException("Contract address not found for token type: {$tokenType->value}");
  289. }
  290. $data = $this->makeRequest('tokentx', [
  291. 'address' => $address,
  292. 'contractaddress' => $contractAddress,
  293. 'startblock' => 0,
  294. 'endblock' => 99999999,
  295. 'page' => $page,
  296. 'offset' => $limit,
  297. 'sort' => 'desc'
  298. ], 'account');
  299. return $data['result'];
  300. }
  301. }