feat: autotransfer
This commit is contained in:
@@ -13,6 +13,8 @@ class TransferService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChainManager $chains,
|
||||
private readonly AutoTransferConfig $autoConfig,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,17 @@ class TransferService
|
||||
|
||||
$asset = strtoupper(trim($asset));
|
||||
$record['asset'] = $asset;
|
||||
|
||||
create_log([
|
||||
'event' => 'transfer_start',
|
||||
'chain' => $chain,
|
||||
'asset' => $asset,
|
||||
'from' => $fromAddress,
|
||||
'to' => $to,
|
||||
'amount' => $record['amount'],
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
|
||||
$driver = $this->chains->resolve($chain);
|
||||
|
||||
if (! $driver->isValidAddress($fromAddress)) {
|
||||
@@ -81,6 +94,11 @@ class TransferService
|
||||
|
||||
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
|
||||
|
||||
// Energy/bandwidth fee: only TRON USDT (TRC20) needs a TRX buffer.
|
||||
if ($asset === 'USDT' && in_array($chain, ['tron', 'trx'], true)) {
|
||||
$this->ensureTronFeeTopup($driver, $fromAddress, $operator);
|
||||
}
|
||||
|
||||
$txid = match ($asset) {
|
||||
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
||||
'USDT' => $driver->sendToken(
|
||||
@@ -111,6 +129,157 @@ class TransferService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TRON USDT only: if fromAddress TRX < fee_topup_trx target, top up the shortfall from the fee wallet.
|
||||
* Example: balance 5, target 20 → send 15 TRX.
|
||||
*/
|
||||
private function ensureTronFeeTopup(ChainDriver $driver, string $fromAddress, ?string $operator): void
|
||||
{
|
||||
if (! $driver instanceof \App\Services\Chain\TronDriver) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = $this->autoConfig->feeTopupTrx();
|
||||
$trx = $driver->getNativeBalance($fromAddress);
|
||||
if (bccomp($trx, $target, 6) >= 0) {
|
||||
create_log([
|
||||
'event' => 'tron_fee_topup_skip',
|
||||
'reason' => 'balance_sufficient',
|
||||
'from' => $fromAddress,
|
||||
'trx' => $trx,
|
||||
'target' => $target,
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$amount = bcsub($target, $trx, 6);
|
||||
$amount = rtrim(rtrim($amount, '0'), '.');
|
||||
if ($amount === '' || str_starts_with($amount, '-') || bccomp($amount, '0', 6) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$feeAddress = $this->autoConfig->feeAddressTron();
|
||||
$feeKey = $this->autoConfig->feePrivateKeyTron();
|
||||
if ($feeAddress === '' || $feeKey === '') {
|
||||
create_log([
|
||||
'event' => 'tron_fee_topup_failed',
|
||||
'error' => 'fee wallet not configured',
|
||||
'from' => $fromAddress,
|
||||
'trx' => $trx,
|
||||
'target' => $target,
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
throw new RuntimeException('TRON fee top-up wallet is not configured (address/private key)');
|
||||
}
|
||||
if (! $driver->isValidAddress($feeAddress)) {
|
||||
throw new RuntimeException('Invalid TRON fee top-up address');
|
||||
}
|
||||
|
||||
try {
|
||||
$derived = \App\Services\Chain\TronDriver::addressFromPrivateKey($feeKey);
|
||||
if (strcasecmp($derived, $feeAddress) !== 0) {
|
||||
throw new RuntimeException('TRON fee private key does not match fee address');
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException('Invalid TRON fee private key: '.$e->getMessage());
|
||||
}
|
||||
|
||||
create_log([
|
||||
'event' => 'tron_fee_topup_start',
|
||||
'fee_from' => $feeAddress,
|
||||
'to' => $fromAddress,
|
||||
'amount' => $amount,
|
||||
'trx_before' => $trx,
|
||||
'target' => $target,
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
|
||||
$topupRecord = [
|
||||
'from_address' => $feeAddress,
|
||||
'to_address' => $fromAddress,
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => null,
|
||||
'amount' => $amount,
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'TRX',
|
||||
'operator' => 'fee-topup'.($operator ? ':'.$operator : ''),
|
||||
'status' => TransferRecord::STATUS_FAILED,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$txid = $driver->sendNativeWithPrivateKey($feeKey, $feeAddress, $fromAddress, $amount);
|
||||
$topupRecord['tx_hash'] = $txid;
|
||||
$topupRecord['status'] = TransferRecord::STATUS_SUCCESS;
|
||||
$this->finish($topupRecord, [
|
||||
'ok' => true,
|
||||
'txid' => $txid,
|
||||
'from' => $feeAddress,
|
||||
'to' => $fromAddress,
|
||||
'amount' => $amount,
|
||||
'asset' => 'TRX',
|
||||
]);
|
||||
create_log([
|
||||
'event' => 'tron_fee_topup_success',
|
||||
'fee_from' => $feeAddress,
|
||||
'to' => $fromAddress,
|
||||
'amount' => $amount,
|
||||
'txid' => $txid,
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
$this->notifyFeeTopupTelegram(
|
||||
feeFrom: $feeAddress,
|
||||
toAddress: $fromAddress,
|
||||
ok: true,
|
||||
amount: $amount,
|
||||
txid: $txid,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$topupRecord['error'] = $e->getMessage();
|
||||
$this->finish($topupRecord, ['ok' => false, 'error' => $e->getMessage()]);
|
||||
create_log([
|
||||
'event' => 'tron_fee_topup_failed',
|
||||
'fee_from' => $feeAddress,
|
||||
'to' => $fromAddress,
|
||||
'amount' => $amount,
|
||||
'error' => $e->getMessage(),
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
$this->notifyFeeTopupTelegram(
|
||||
feeFrom: $feeAddress,
|
||||
toAddress: $fromAddress,
|
||||
ok: false,
|
||||
amount: $amount,
|
||||
error: $e->getMessage(),
|
||||
);
|
||||
throw new RuntimeException('TRON fee top-up failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$deadline = microtime(true) + 45;
|
||||
do {
|
||||
if (! app()->runningUnitTests()) {
|
||||
usleep(1_500_000);
|
||||
}
|
||||
$trx = $driver->getNativeBalance($fromAddress);
|
||||
if (bccomp($trx, $target, 6) >= 0) {
|
||||
return;
|
||||
}
|
||||
} while (microtime(true) < $deadline);
|
||||
|
||||
create_log([
|
||||
'event' => 'tron_fee_topup_confirm_timeout',
|
||||
'to' => $fromAddress,
|
||||
'trx_after' => $trx,
|
||||
'target' => $target,
|
||||
'operator' => $operator,
|
||||
], 'transfer');
|
||||
throw new RuntimeException('TRON fee top-up sent but balance still below '.$target);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
|
||||
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
|
||||
@@ -155,9 +324,92 @@ class TransferService
|
||||
'error' => $record['error'],
|
||||
], 'transfer');
|
||||
|
||||
$this->notifyTransferTelegram($record, $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
|
||||
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
|
||||
*/
|
||||
private function notifyTransferTelegram(array $record, array $result): void
|
||||
{
|
||||
$operator = (string) ($record['operator'] ?? '');
|
||||
if ($operator !== 'auto') {
|
||||
return;
|
||||
}
|
||||
|
||||
$from = (string) ($record['from_address'] ?? '');
|
||||
$deviceKey = $this->deviceKeyForAddress($from);
|
||||
if ($deviceKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->telegram->notifyAutoTransfer(
|
||||
$deviceKey,
|
||||
$from,
|
||||
(string) ($record['chain'] ?? ''),
|
||||
(string) ($record['asset'] ?? ''),
|
||||
(bool) ($result['ok'] ?? false),
|
||||
isset($record['amount']) ? (string) $record['amount'] : null,
|
||||
isset($record['to_address']) ? (string) $record['to_address'] : null,
|
||||
isset($record['tx_hash']) ? (string) $record['tx_hash'] : null,
|
||||
isset($record['error']) ? (string) $record['error'] : ($result['error'] ?? null),
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// never break transfer for telegram
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyFeeTopupTelegram(
|
||||
string $feeFrom,
|
||||
string $toAddress,
|
||||
bool $ok,
|
||||
?string $amount = null,
|
||||
?string $txid = null,
|
||||
?string $error = null,
|
||||
): void {
|
||||
$deviceKey = $this->deviceKeyForAddress($toAddress);
|
||||
if ($deviceKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->telegram->notifyFeeTopup(
|
||||
$deviceKey,
|
||||
$feeFrom,
|
||||
$toAddress,
|
||||
$ok,
|
||||
$amount,
|
||||
$txid,
|
||||
$error,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// never break transfer for telegram
|
||||
}
|
||||
}
|
||||
|
||||
private function deviceKeyForAddress(string $address): string
|
||||
{
|
||||
$address = trim($address);
|
||||
if ($address === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$devicePk = WalletAddress::query()->where('address', $address)->value('device_id');
|
||||
if ($devicePk === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) (\App\Models\Device::query()->whereKey($devicePk)->value('device_id') ?: '');
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function toAddress(string $chain): string
|
||||
{
|
||||
return match ($chain) {
|
||||
@@ -235,14 +487,14 @@ class TransferService
|
||||
$addressRows = WalletAddress::query()
|
||||
->where('address', $fromAddress)
|
||||
->orderBy('id')
|
||||
->get(['device_id', 'source']);
|
||||
->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
|
||||
|
||||
// ETH addresses are often stored with mixed-case checksum.
|
||||
if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) {
|
||||
$addressRows = WalletAddress::query()
|
||||
->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)])
|
||||
->orderBy('id')
|
||||
->get(['device_id', 'source']);
|
||||
->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
|
||||
}
|
||||
|
||||
if ($addressRows->isEmpty()) {
|
||||
@@ -253,6 +505,29 @@ class TransferService
|
||||
$caseInsensitive = $driver->chainId() === 'eth';
|
||||
|
||||
foreach ($addressRows as $row) {
|
||||
if ($row->mnemonic_id !== null) {
|
||||
$linked = WalletMnemonic::query()->find($row->mnemonic_id);
|
||||
$phrase = $linked?->mnemonic;
|
||||
if ($phrase !== null && trim($phrase) !== '') {
|
||||
if ($row->derive_index !== null) {
|
||||
return ['mnemonic' => $phrase, 'index' => (int) $row->derive_index];
|
||||
}
|
||||
for ($index = 0; $index <= MnemonicAddressLinker::MAX_DERIVE_INDEX; $index++) {
|
||||
try {
|
||||
$derived = $driver->deriveAddress($phrase, $index);
|
||||
$match = $caseInsensitive
|
||||
? strcasecmp($derived, $fromAddress) === 0
|
||||
: $derived === $fromAddress;
|
||||
if ($match) {
|
||||
return ['mnemonic' => $phrase, 'index' => $index];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$mnemonics = WalletMnemonic::query()
|
||||
->where('device_id', $row->device_id)
|
||||
->where(function ($q) use ($row) {
|
||||
|
||||
Reference in New Issue
Block a user