whisper-money/app/Services/Banking/BinanceClient.php

150 lines
4.9 KiB
PHP

<?php
namespace App\Services\Banking;
use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BinanceClient
{
private const BASE_URL = 'https://api.binance.com';
/** @var array<int, int> Retry backoff: 10s, 30s, 60s */
private const RETRY_BACKOFF_MS = [10_000, 30_000, 60_000];
public function __construct(
private string $apiKey,
private string $apiSecret,
) {}
/**
* Get account information including balances, omitting zero-balance assets.
*
* @return array{balances: array<int, array{asset: string, free: string, locked: string}>}
*/
public function getAccount(): array
{
return $this->signedRequest('/api/v3/account', ['omitZeroBalances' => 'true']);
}
/**
* Get all ticker prices for trading pairs.
*
* @return array<int, array{symbol: string, price: string}>
*/
public function getTickerPrices(): array
{
$response = $this->publicClient()->get('/api/v3/ticker/price');
$response->throw();
return $response->json();
}
/**
* Get daily account snapshots (up to 30 per request, max 180 days history).
*
* @return array{snapshotVos: array<int, array{type: string, updateTime: int, data: array{balances: array<int, array{asset: string, free: string, locked: string}>, totalAssetOfBtc: string}}>}
*/
public function getAccountSnapshots(int $startTime, int $endTime, int $limit = 30): array
{
return $this->signedRequest('/sapi/v1/accountSnapshot', [
'type' => 'SPOT',
'startTime' => $startTime,
'endTime' => $endTime,
'limit' => $limit,
]);
}
/**
* Get deposit history with optional time range and pagination.
* Default time range is 90 days. Max window is 90 days per request.
*
* @return array<int, array{id: string, amount: string, coin: string, network: string, status: int, insertTime: int, transferType: int}>
*/
public function getDepositHistory(?int $startTime = null, ?int $endTime = null, int $offset = 0, int $limit = 1000): array
{
$params = ['offset' => $offset, 'limit' => $limit];
if ($startTime !== null) {
$params['startTime'] = $startTime;
}
if ($endTime !== null) {
$params['endTime'] = $endTime;
}
return $this->signedRequest('/sapi/v1/capital/deposit/hisrec', $params);
}
/**
* Get withdrawal history with optional time range and pagination.
* Default time range is 90 days. Max window is 90 days per request.
*
* @return array<int, array{id: string, amount: string, coin: string, network: string, status: int, applyTime: string, transferType: int}>
*/
public function getWithdrawHistory(?int $startTime = null, ?int $endTime = null, int $offset = 0, int $limit = 1000): array
{
$params = ['offset' => $offset, 'limit' => $limit];
if ($startTime !== null) {
$params['startTime'] = $startTime;
}
if ($endTime !== null) {
$params['endTime'] = $endTime;
}
return $this->signedRequest('/sapi/v1/capital/withdraw/history', $params);
}
/**
* Execute a signed request with fresh timestamp on each retry attempt.
*/
private function signedRequest(string $path, array $params = []): array
{
return retry(
self::RETRY_BACKOFF_MS,
function () use ($path, $params) {
$params['timestamp'] = (int) (microtime(true) * 1000);
$queryString = http_build_query($params);
$signature = hash_hmac('sha256', $queryString, $this->apiSecret);
$response = $this->authenticatedClient()
->get("{$path}?{$queryString}&signature={$signature}");
$response->throw();
return $response->json();
},
when: fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429,
);
}
private function authenticatedClient(): PendingRequest
{
return Http::baseUrl(self::BASE_URL)
->withHeaders(['X-MBX-APIKEY' => $this->apiKey])
->acceptJson()
->throw(function ($response, $exception) {
Log::error('Binance API error', [
'status' => $response->status(),
'body' => $response->json(),
]);
});
}
private function publicClient(): PendingRequest
{
return Http::baseUrl(self::BASE_URL)
->acceptJson()
->retry(
self::RETRY_BACKOFF_MS,
when: fn (\Throwable $e) => $e instanceof RequestException && $e->response->status() === 429,
);
}
}