feat: autotransfer

This commit is contained in:
hashbro
2026-08-17 02:03:17 +08:00
parent 376b442880
commit a32043df71
42 changed files with 3268 additions and 256 deletions
+277 -2
View File
@@ -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) {