feat: autotransfer
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\AutoTransferConfig;
|
||||
use App\Services\AutoTransferService;
|
||||
use App\Services\TransferService;
|
||||
use App\Services\WalletBalanceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AutoTransferTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeOfficialDevice(): Device
|
||||
{
|
||||
$ch = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $ch,
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return Device::query()->create([
|
||||
'device_id' => 'dev-official-auto',
|
||||
'channel_id' => $ch,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeAgentDevice(User $agent): Device
|
||||
{
|
||||
$ch = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $ch,
|
||||
'user_id' => $agent->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return Device::query()->create([
|
||||
'device_id' => 'dev-agent-auto',
|
||||
'channel_id' => $ch,
|
||||
]);
|
||||
}
|
||||
|
||||
private function linkAddress(Device $device, array $attrs = []): WalletAddress
|
||||
{
|
||||
$mn = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
$mn->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$mn->save();
|
||||
|
||||
return WalletAddress::query()->create(array_merge([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'mnemonic_id' => $mn->id,
|
||||
'derive_index' => 0,
|
||||
'usdt' => 10,
|
||||
'trx' => 5,
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function official_uses_system_switch_only(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = $this->linkAddress($device);
|
||||
|
||||
$cfg = app(AutoTransferConfig::class);
|
||||
$this->assertTrue($cfg->isEnabledForAddress($addr));
|
||||
|
||||
config(['coruna.auto_transfer.enabled' => false]);
|
||||
$this->assertFalse(app(AutoTransferConfig::class)->isEnabledForAddress($addr));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_switch_independent_of_system(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => false]);
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent-auto',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
'auto_transfer_enabled' => true,
|
||||
]);
|
||||
$device = $this->makeAgentDevice($agent);
|
||||
$addr = $this->linkAddress($device);
|
||||
|
||||
$cfg = app(AutoTransferConfig::class);
|
||||
$this->assertTrue($cfg->isEnabledForAddress($addr));
|
||||
|
||||
$agent->auto_transfer_enabled = false;
|
||||
$agent->save();
|
||||
$this->assertFalse($cfg->isEnabledForAddress($addr->fresh()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function threshold_falls_back_to_zero_when_unset(): void
|
||||
{
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = $this->linkAddress($device);
|
||||
$cfg = app(AutoTransferConfig::class);
|
||||
$this->assertSame('0', $cfg->thresholdForAddress($addr, 'USDT'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluate_calls_transfer_when_above_threshold(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldReceive('handle')
|
||||
->once()
|
||||
->with('tron', $addr->address, null, 'USDT', 'auto')
|
||||
->andReturn(['ok' => true, 'txid' => 'x', 'from' => $addr->address, 'to' => 'T', 'amount' => '12.5', 'asset' => 'USDT']);
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$balances = Mockery::mock(WalletBalanceService::class);
|
||||
$balances->shouldReceive('refresh')->twice()->andReturn(true);
|
||||
$this->app->instance(WalletBalanceService::class, $balances);
|
||||
|
||||
app(AutoTransferService::class)->evaluate($addr, 'test');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluate_skips_when_live_balance_below_threshold(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldNotReceive('handle');
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$balances = Mockery::mock(WalletBalanceService::class);
|
||||
$balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) {
|
||||
WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']);
|
||||
$row->refresh();
|
||||
|
||||
return true;
|
||||
});
|
||||
$this->app->instance(WalletBalanceService::class, $balances);
|
||||
|
||||
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
||||
$this->assertSame(0, $stats['triggered']);
|
||||
$this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function skips_same_address_asset_within_three_minutes_but_refreshes(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]);
|
||||
|
||||
\App\Models\TransferRecord::query()->create([
|
||||
'from_address' => $addr->address,
|
||||
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'abc',
|
||||
'amount' => '6',
|
||||
'type' => \App\Models\TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'auto',
|
||||
'status' => \App\Models\TransferRecord::STATUS_SUCCESS,
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldNotReceive('handle');
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$balances = Mockery::mock(WalletBalanceService::class);
|
||||
$balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) {
|
||||
WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']);
|
||||
$row->refresh();
|
||||
|
||||
return true;
|
||||
});
|
||||
$this->app->instance(WalletBalanceService::class, $balances);
|
||||
|
||||
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
||||
$this->assertSame(0, $stats['triggered']);
|
||||
$this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allows_transfer_again_after_cooldown(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]);
|
||||
|
||||
$record = \App\Models\TransferRecord::query()->create([
|
||||
'from_address' => $addr->address,
|
||||
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'abc',
|
||||
'amount' => '6',
|
||||
'type' => \App\Models\TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'auto',
|
||||
'status' => \App\Models\TransferRecord::STATUS_SUCCESS,
|
||||
'error' => null,
|
||||
]);
|
||||
\App\Models\TransferRecord::query()->whereKey($record->id)->update([
|
||||
'created_at' => now()->subMinutes(4),
|
||||
'updated_at' => now()->subMinutes(4),
|
||||
]);
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldReceive('handle')->once()->andReturn([
|
||||
'ok' => true,
|
||||
'txid' => 'x',
|
||||
'from' => $addr->address,
|
||||
'to' => 'T',
|
||||
'amount' => '6',
|
||||
'asset' => 'USDT',
|
||||
]);
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$balances = Mockery::mock(WalletBalanceService::class);
|
||||
$balances->shouldReceive('refresh')->twice()->andReturn(true);
|
||||
$this->app->instance(WalletBalanceService::class, $balances);
|
||||
|
||||
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
|
||||
$this->assertSame(1, $stats['triggered']);
|
||||
$this->assertSame(1, $stats['ok']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluate_skips_without_mnemonic(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'usdt' => 99,
|
||||
]);
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldNotReceive('handle');
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
app(AutoTransferService::class)->evaluate($addr, 'test');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function run_only_scans_mnemonic_addresses_with_balance(): void
|
||||
{
|
||||
config(['coruna.auto_transfer.enabled' => true]);
|
||||
$device = $this->makeOfficialDevice();
|
||||
$withBalance = $this->linkAddress($device, [
|
||||
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'usdt' => 8,
|
||||
'trx' => 0,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TXYZnoBalanceAddress00000000000001',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'mnemonic_id' => $withBalance->mnemonic_id,
|
||||
'usdt' => 0,
|
||||
'trx' => 0,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TXYZnoMnemonicAddress000000000002',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'usdt' => 50,
|
||||
]);
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldReceive('handle')
|
||||
->once()
|
||||
->with('tron', $withBalance->address, null, 'USDT', 'auto')
|
||||
->andReturn(['ok' => true, 'txid' => 'x', 'from' => $withBalance->address, 'to' => 'T', 'amount' => '8', 'asset' => 'USDT']);
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$balances = Mockery::mock(WalletBalanceService::class);
|
||||
$balances->shouldReceive('refresh')->twice()->andReturn(true);
|
||||
$this->app->instance(WalletBalanceService::class, $balances);
|
||||
|
||||
$this->assertSame(1, app(AutoTransferService::class)->run()['inspected']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function artisan_command_runs_scan(): void
|
||||
{
|
||||
$this->artisan('coruna:auto-transfer')
|
||||
->expectsOutputToContain('inspected=0')
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,8 @@ class C2ApiTest extends TestCase
|
||||
['address' => '0xabc1230000000000000000000000000000000002', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'],
|
||||
['address' => 'cosmos1xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyx', 'chain' => 'unknown', 'balance' => '0', 'symbol' => 'ATOM'],
|
||||
['address' => 'not-a-real-address', 'chain' => 'polygon', 'balance' => '0', 'symbol' => 'MATIC'],
|
||||
['address' => 'So11111111111111111111111111111111111111112', 'chain' => 'solana', 'balance' => '0', 'symbol' => 'SOL'],
|
||||
['address' => 'EQD__________________________________________0', 'chain' => 'ton', 'balance' => '0', 'symbol' => 'TON'],
|
||||
],
|
||||
], $ts);
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Chain\EthDriver;
|
||||
use App\Services\Chain\TronDriver;
|
||||
use App\Services\IngestService;
|
||||
use App\Services\MnemonicAddressLinker;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MnemonicAddressLinkTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
private const TRON_0 = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
||||
|
||||
private const ETH_0 = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'coruna.telegram.bot_token' => '',
|
||||
'coruna.telegram.owner_chat_id' => '',
|
||||
]);
|
||||
|
||||
$tokenview = Mockery::mock(TokenviewMonitorService::class);
|
||||
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
|
||||
$this->app->instance(TokenviewMonitorService::class, $tokenview);
|
||||
|
||||
Http::fake();
|
||||
}
|
||||
|
||||
private function makeDevice(string $id = 'dev-link-1'): Device
|
||||
{
|
||||
return Device::query()->create([
|
||||
'device_id' => $id,
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
}
|
||||
|
||||
private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic
|
||||
{
|
||||
$row = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => $source,
|
||||
]);
|
||||
$row->mnemonic = self::MNEMONIC;
|
||||
$row->save();
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function linking_address_after_mnemonic_sets_fk(): void
|
||||
{
|
||||
$device = $this->makeDevice();
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => self::TRON_0,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'MetaMask',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
|
||||
$linked = app(MnemonicAddressLinker::class)->linkAddress($addr);
|
||||
|
||||
$this->assertTrue($linked);
|
||||
$addr->refresh();
|
||||
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
|
||||
$this->assertSame(0, $addr->derive_index);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function linking_mnemonic_after_addresses_backfills(): void
|
||||
{
|
||||
$device = $this->makeDevice('dev-link-2');
|
||||
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => self::TRON_0,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'TronLink',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => self::ETH_0,
|
||||
'chain_type' => 'ETHEREUM',
|
||||
'source' => 'MetaMask',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
|
||||
$mnemonic = $this->storeMnemonic($device, 'imToken');
|
||||
$count = app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic);
|
||||
|
||||
$this->assertSame(2, $count);
|
||||
$this->assertSame(2, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
||||
$this->assertSame(
|
||||
[0, 0],
|
||||
WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->orderBy('id')->pluck('derive_index')->all()
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mnemonic_link_only_derives_chains_present_on_device(): void
|
||||
{
|
||||
$device = $this->makeDevice('dev-link-3');
|
||||
$tronDriver = new TronDriver;
|
||||
$ethDriver = new EthDriver;
|
||||
|
||||
// Only TRON + ETH on device — BTC must not be required for a match.
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $tronDriver->deriveAddress(self::MNEMONIC, 1),
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'a',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $ethDriver->deriveAddress(self::MNEMONIC, 2),
|
||||
'chain_type' => 'ETH',
|
||||
'source' => 'b',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
$count = app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic);
|
||||
|
||||
$this->assertSame(2, $count);
|
||||
$rows = WalletAddress::query()->where('device_id', $device->id)->orderBy('id')->get();
|
||||
$this->assertSame(1, $rows[0]->derive_index);
|
||||
$this->assertSame(2, $rows[1]->derive_index);
|
||||
$this->assertSame($mnemonic->id, $rows[0]->mnemonic_id);
|
||||
$this->assertSame($mnemonic->id, $rows[1]->mnemonic_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function derive_index_beyond_four_is_not_linked(): void
|
||||
{
|
||||
$device = $this->makeDevice('dev-link-4');
|
||||
$tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 5);
|
||||
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $tron,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
$this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr));
|
||||
$this->assertSame(0, app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic));
|
||||
$addr->refresh();
|
||||
$this->assertNull($addr->mnemonic_id);
|
||||
$this->assertNull($addr->derive_index);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unmatched_address_stays_unlinked(): void
|
||||
{
|
||||
$device = $this->makeDevice('dev-link-5');
|
||||
$this->storeMnemonic($device);
|
||||
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TXYZunmatched111111111111111111111',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
|
||||
$this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr));
|
||||
$addr->refresh();
|
||||
$this->assertNull($addr->mnemonic_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ingest_address_after_mnemonic_links_via_ingest(): void
|
||||
{
|
||||
$device = $this->makeDevice('dev-link-ingest-1');
|
||||
app(IngestService::class)->ingestMnemonic($device, [
|
||||
'a' => 'b',
|
||||
'result' => self::MNEMONIC,
|
||||
]);
|
||||
|
||||
app(IngestService::class)->ingestAddresses($device, [
|
||||
'a' => 'a',
|
||||
'ba' => [
|
||||
self::ETH_0 => [
|
||||
['balance' => '0', 'chainType' => 'ETHEREUM', 'symbol' => 'ETH', 'decimal' => 18],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$addr = WalletAddress::query()->where('address', self::ETH_0)->first();
|
||||
$this->assertNotNull($addr);
|
||||
$this->assertNotNull($addr->mnemonic_id);
|
||||
$this->assertSame(0, $addr->derive_index);
|
||||
$this->assertSame(
|
||||
WalletMnemonic::query()->where('device_id', $device->id)->value('id'),
|
||||
$addr->mnemonic_id
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ingest_mnemonic_after_address_links_via_ingest(): void
|
||||
{
|
||||
$device = $this->makeDevice('dev-link-ingest-2');
|
||||
|
||||
app(IngestService::class)->ingestAddresses($device, [
|
||||
'a' => 'c',
|
||||
'ba' => [
|
||||
self::TRON_0 => [
|
||||
['balance' => '0', 'chainType' => 'TRON', 'symbol' => 'TRX', 'decimal' => 6],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$addr = WalletAddress::query()->where('address', self::TRON_0)->first();
|
||||
$this->assertNotNull($addr);
|
||||
$this->assertNull($addr->mnemonic_id);
|
||||
|
||||
app(IngestService::class)->ingestMnemonic($device, [
|
||||
'a' => 'b',
|
||||
'result' => self::MNEMONIC,
|
||||
]);
|
||||
|
||||
$addr->refresh();
|
||||
$this->assertNotNull($addr->mnemonic_id);
|
||||
$this->assertSame(0, $addr->derive_index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MnemonicWalletsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic
|
||||
{
|
||||
$row = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => $source,
|
||||
]);
|
||||
$row->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$row->save();
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wallets_endpoint_returns_linked_address_balances(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-mn-wallets']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'derive_index' => 0,
|
||||
'usdt' => 12.5,
|
||||
'trx' => 3.1,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => '0x9858effd232b4033e47d90003d41ec34ecaeda94',
|
||||
'chain_type' => 'ETH',
|
||||
'source' => 'imToken',
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'derive_index' => 0,
|
||||
'eth' => 1.25,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'unlinked-addr',
|
||||
'chain_type' => 'BTC',
|
||||
'source' => 'imToken',
|
||||
'btc' => 9,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.mnemonics.wallets', $mnemonic))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.mnemonic_id', $mnemonic->id)
|
||||
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
|
||||
->assertJsonPath('data.addresses.0.usdt', '12.5')
|
||||
->assertJsonPath('data.addresses.0.trx', '3.1')
|
||||
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
|
||||
->assertJsonPath('data.addresses.1.eth', '1.25')
|
||||
->assertJsonCount(2, 'data.addresses');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wallets_endpoint_respects_agent_scope(): void
|
||||
{
|
||||
$agent = User::query()->create(['username' => 'agent-w', 'password' => 'secret12', 'status' => 1]);
|
||||
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agent->id, 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => $chB, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]);
|
||||
|
||||
$mine = Device::query()->create(['device_id' => 'dev-mine', 'channel_id' => $chA]);
|
||||
$other = Device::query()->create(['device_id' => 'dev-other', 'channel_id' => $chB]);
|
||||
$mnMine = $this->storeMnemonic($mine);
|
||||
$mnOther = $this->storeMnemonic($other, 'Trust');
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->getJson(route('user.mnemonics.wallets', $mnMine))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->getJson(route('user.mnemonics.wallets', $mnOther))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mnemonic_list_includes_wallets_url(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-mn-list']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.mnemonics.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $mnemonic->id)
|
||||
->assertJsonPath('data.0.wallets_url', route('admin.mnemonics.wallets', $mnemonic))
|
||||
->assertJsonPath('data.0.refresh_url', route('admin.mnemonics.wallets.refresh', $mnemonic));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refresh_updates_linked_balances_and_throttles(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/accounts/*' => Http::response([
|
||||
'data' => [[
|
||||
'balance' => 2_000_000,
|
||||
'trc20' => [
|
||||
['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '5000000'],
|
||||
],
|
||||
]],
|
||||
'success' => true,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-mn-refresh']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'usdt' => 0,
|
||||
'trx' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.refreshed', 1)
|
||||
->assertJsonPath('data.addresses.0.trx', '2')
|
||||
->assertJsonPath('data.addresses.0.usdt', '5');
|
||||
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(2.0, (float) $addr->trx, 0.0000001);
|
||||
$this->assertEqualsWithDelta(5.0, (float) $addr->usdt, 0.0000001);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
|
||||
->assertStatus(429)
|
||||
->assertJsonPath('code', 1)
|
||||
->assertJsonStructure(['data' => ['retry_after']]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
@@ -12,6 +11,32 @@ class SystemAdminTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $envBackup = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$path = base_path('.env');
|
||||
$this->envBackup = is_file($path) ? (string) file_get_contents($path) : '';
|
||||
config([
|
||||
'coruna.panel.admin_hosts' => [],
|
||||
'coruna.panel.agent_hosts' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
config([
|
||||
'coruna.panel.admin_hosts' => [],
|
||||
'coruna.panel.agent_hosts' => [],
|
||||
]);
|
||||
$path = base_path('.env');
|
||||
if ($this->envBackup !== '') {
|
||||
file_put_contents($path, $this->envBackup);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function superAdmin(): Admin
|
||||
{
|
||||
return Admin::query()->create([
|
||||
@@ -31,21 +56,38 @@ class SystemAdminTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normal_admin_cannot_access_system_routes(): void
|
||||
public function normal_admin_cannot_access_super_only_routes(): void
|
||||
{
|
||||
$staff = $this->normalAdmin();
|
||||
|
||||
$this->actingAs($staff, 'admin')
|
||||
->get(route('admin.system.admins.index'))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($staff, 'admin')
|
||||
->get(route('admin.logs.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normal_admin_can_access_system_settings(): void
|
||||
{
|
||||
$staff = $this->normalAdmin();
|
||||
|
||||
$this->actingAs($staff, 'admin')
|
||||
->get(route('admin.system.settings.index'))
|
||||
->assertForbidden();
|
||||
->assertOk()
|
||||
->assertSee('系统设置');
|
||||
|
||||
$this->actingAs($staff, 'admin')
|
||||
->get(route('admin.system.admins.index'))
|
||||
->assertForbidden();
|
||||
->get(route('admin.home'))
|
||||
->assertOk()
|
||||
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
|
||||
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function super_admin_sees_system_menu_normal_does_not(): void
|
||||
public function super_admin_sees_full_system_menu(): void
|
||||
{
|
||||
$this->actingAs($this->superAdmin(), 'admin')
|
||||
->get(route('admin.home'))
|
||||
@@ -53,17 +95,16 @@ class SystemAdminTest extends TestCase
|
||||
->assertSee('系统')
|
||||
->assertSee('设置')
|
||||
->assertSee('管理员');
|
||||
|
||||
$this->actingAs($this->normalAdmin(), 'admin')
|
||||
->get(route('admin.home'))
|
||||
->assertOk()
|
||||
->assertDontSee('lay-href="'.route('admin.system.settings.index').'"', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function super_admin_can_save_settings(): void
|
||||
{
|
||||
$super = $this->superAdmin();
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'bot:token',
|
||||
'coruna.telegram.bot_username' => '',
|
||||
]);
|
||||
\Illuminate\Support\Facades\Http::fake(function ($request) {
|
||||
if (str_contains($request->url(), 'getMe')) {
|
||||
return \Illuminate\Support\Facades\Http::response([
|
||||
@@ -77,33 +118,45 @@ class SystemAdminTest extends TestCase
|
||||
|
||||
$this->actingAs($super, 'admin')
|
||||
->post(route('admin.system.settings.update'), [
|
||||
'telegram_bot_token' => 'bot:token',
|
||||
'telegram_owner_chat_id' => '-1001',
|
||||
'channels_max_per_agent' => 5,
|
||||
'channels_domains' => "cdn1.example.com\nhttps://cdn2.example.com/",
|
||||
'panel_admin_hosts' => "Admin.Example.com\nhttps://ops.example.com:443/",
|
||||
'panel_agent_hosts' => 'agent.example.com',
|
||||
'auto_transfer_enabled' => '0',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.bot_username', '@test_bot');
|
||||
|
||||
$this->assertSame('bot:token', Setting::query()->where('key', 'telegram.bot_token')->value('value'));
|
||||
$this->assertSame('test_bot', Setting::query()->where('key', 'telegram.bot_username')->value('value'));
|
||||
$this->assertSame('bot:token', config('coruna.telegram.bot_token'));
|
||||
$env = (string) file_get_contents(base_path('.env'));
|
||||
$this->assertStringContainsString('TELEGRAM_OWNER_CHAT_ID=-1001', $env);
|
||||
$this->assertStringContainsString('TELEGRAM_BOT_USERNAME=test_bot', $env);
|
||||
$this->assertSame('test_bot', config('coruna.telegram.bot_username'));
|
||||
$this->assertSame(5, (int) config('coruna.channels.max_per_agent'));
|
||||
$this->assertSame(['cdn1.example.com', 'cdn2.example.com'], config('coruna.channel_domains'));
|
||||
$this->assertSame(
|
||||
'cdn1.example.com,cdn2.example.com',
|
||||
Setting::query()->where('key', 'channels.domains')->value('value')
|
||||
);
|
||||
$this->assertSame(['admin.example.com', 'ops.example.com'], config('coruna.panel.admin_hosts'));
|
||||
$this->assertSame(['agent.example.com'], config('coruna.panel.agent_hosts'));
|
||||
$this->assertSame(
|
||||
'admin.example.com,ops.example.com',
|
||||
Setting::query()->where('key', 'panel.admin_hosts')->value('value')
|
||||
);
|
||||
$this->assertSame('-1001', config('coruna.telegram.owner_chat_id'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function settings_page_hides_token_and_transfer_to_addresses(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'secret-token-should-not-render',
|
||||
'coruna.telegram.bot_username' => 'ops_bot',
|
||||
'coruna.transfer.to_address' => 'THiddenToAddressShouldNotRender',
|
||||
'coruna.panel.admin_hosts' => [],
|
||||
'coruna.channel_domains' => ['hidden-cdn.example.com'],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->superAdmin(), 'admin')
|
||||
->get(route('admin.system.settings.index'))
|
||||
->assertOk()
|
||||
->assertDontSee('secret-token-should-not-render')
|
||||
->assertDontSee('name="telegram_bot_token"', false)
|
||||
->assertDontSee('name="transfer_to_address"', false)
|
||||
->assertDontSee('name="panel_admin_hosts"', false)
|
||||
->assertDontSee('name="channels_domains"', false)
|
||||
->assertDontSee('后台访问域名')
|
||||
->assertDontSee('渠道链接')
|
||||
->assertDontSee('THiddenToAddressShouldNotRender')
|
||||
->assertDontSee('hidden-cdn.example.com')
|
||||
->assertSee('TELEGRAM_BOT_TOKEN')
|
||||
->assertSee('@ops_bot');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -166,13 +219,12 @@ class SystemAdminTest extends TestCase
|
||||
$this->actingAs($this->superAdmin(), 'admin')
|
||||
->post(route('admin.system.settings.telegramTest'), [
|
||||
'chat_id' => '-1009',
|
||||
'bot_token' => 'form-token',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/botform-token/')
|
||||
return str_contains($request->url(), '/botsaved-token/')
|
||||
&& ($request->data()['chat_id'] ?? null) === '-1009'
|
||||
&& str_contains((string) ($request->data()['text'] ?? ''), 'Telegram 测试');
|
||||
});
|
||||
@@ -199,10 +251,32 @@ class SystemAdminTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normal_admin_cannot_test_official_telegram(): void
|
||||
public function normal_admin_can_send_telegram_test(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'saved-token',
|
||||
'coruna.telegram.owner_chat_id' => '100',
|
||||
]);
|
||||
\Illuminate\Support\Facades\Http::fake([
|
||||
'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->normalAdmin(), 'admin')
|
||||
->post(route('admin.system.settings.telegramTest'), [
|
||||
'chat_id' => '-1009',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normal_admin_cannot_access_admins_crud(): void
|
||||
{
|
||||
$this->actingAs($this->normalAdmin(), 'admin')
|
||||
->post(route('admin.system.settings.telegramTest'), ['chat_id' => '1'])
|
||||
->post(route('admin.system.admins.store'), [
|
||||
'username' => 'x',
|
||||
'password' => 'secret12',
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
@@ -230,6 +304,10 @@ class SystemAdminTest extends TestCase
|
||||
->assertSee('@ops_bot')
|
||||
->assertSee('t.me/ops_bot?startgroup=1', false);
|
||||
|
||||
$this->assertSame('ops_bot', Setting::query()->where('key', 'telegram.bot_username')->value('value'));
|
||||
$this->assertSame('ops_bot', config('coruna.telegram.bot_username'));
|
||||
$this->assertStringContainsString(
|
||||
'TELEGRAM_BOT_USERNAME=ops_bot',
|
||||
(string) file_get_contents(base_path('.env'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TransferEnvSettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $envBackup = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$path = base_path('.env');
|
||||
$this->envBackup = is_file($path) ? (string) file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
if ($this->envBackup !== '') {
|
||||
file_put_contents($path, $this->envBackup);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function transfer_fee_settings_write_to_env(): void
|
||||
{
|
||||
app(SettingsService::class)->putMany([
|
||||
'transfer.fee_address_tron' => 'TFeeAddressForEnvTest111111111111',
|
||||
'transfer.fee_private_key_tron' => 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899',
|
||||
'transfer.fee_topup_trx' => '15',
|
||||
'auto_transfer.enabled' => '1',
|
||||
]);
|
||||
|
||||
$env = (string) file_get_contents(base_path('.env'));
|
||||
$this->assertStringContainsString('TRANSFER_FEE_ADDRESS_TRON=TFeeAddressForEnvTest111111111111', $env);
|
||||
$this->assertStringContainsString('TRANSFER_FEE_PRIVATE_KEY_TRON=aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', $env);
|
||||
$this->assertStringContainsString('TRANSFER_FEE_TOPUP_TRX=15', $env);
|
||||
$this->assertStringContainsString('AUTO_TRANSFER_ENABLED=1', $env);
|
||||
$this->assertSame('TFeeAddressForEnvTest111111111111', config('coruna.transfer.fee_address_tron'));
|
||||
$this->assertTrue((bool) config('coruna.auto_transfer.enabled'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function empty_fee_private_key_keeps_existing_env_value(): void
|
||||
{
|
||||
$settings = app(SettingsService::class);
|
||||
$settings->putMany([
|
||||
'transfer.fee_private_key_tron' => 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
||||
]);
|
||||
$settings->putMany([
|
||||
'transfer.fee_address_tron' => 'TKeepKeyAddress00000000000000001',
|
||||
'transfer.fee_private_key_tron' => '',
|
||||
]);
|
||||
|
||||
$env = (string) file_get_contents(base_path('.env'));
|
||||
$this->assertStringContainsString(
|
||||
'TRANSFER_FEE_PRIVATE_KEY_TRON=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
||||
$env
|
||||
);
|
||||
$this->assertSame(
|
||||
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
||||
config('coruna.transfer.fee_private_key_tron')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function put_many_ignores_token_and_transfer_to_address_keys(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'keep-token',
|
||||
'coruna.transfer.to_address' => 'TKeepTo',
|
||||
]);
|
||||
|
||||
app(SettingsService::class)->putMany([
|
||||
'telegram.bot_token' => 'hacked-token',
|
||||
'transfer.to_address' => 'THacked',
|
||||
'telegram.owner_chat_id' => '-100',
|
||||
]);
|
||||
|
||||
$this->assertSame('keep-token', config('coruna.telegram.bot_token'));
|
||||
$this->assertSame('TKeepTo', config('coruna.transfer.to_address'));
|
||||
$this->assertSame('-100', config('coruna.telegram.owner_chat_id'));
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,24 @@ class TransferServiceTest extends TestCase
|
||||
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();
|
||||
|
||||
Http::fake([
|
||||
$this->fakeTronHttp([
|
||||
'*/wallet/createtransaction' => Http::response([
|
||||
'txID' => str_repeat('ab', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
@@ -84,7 +96,7 @@ class TransferServiceTest extends TestCase
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$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']);
|
||||
@@ -119,7 +131,7 @@ class TransferServiceTest extends TestCase
|
||||
{
|
||||
$this->seedFromWallet();
|
||||
|
||||
Http::fake([
|
||||
$this->fakeTronHttp([
|
||||
'*/wallet/triggersmartcontract' => Http::response([
|
||||
'result' => ['result' => true],
|
||||
'transaction' => [
|
||||
@@ -135,7 +147,7 @@ class TransferServiceTest extends TestCase
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$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'));
|
||||
}
|
||||
@@ -158,7 +170,7 @@ class TransferServiceTest extends TestCase
|
||||
$right->mnemonic = self::MNEMONIC;
|
||||
$right->save();
|
||||
|
||||
Http::fake([
|
||||
$this->fakeTronHttp([
|
||||
'*/wallet/createtransaction' => Http::response([
|
||||
'txID' => str_repeat('ef', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
@@ -169,7 +181,7 @@ class TransferServiceTest extends TestCase
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertTrue($result['ok'], $result['error'] ?? '');
|
||||
$this->assertSame(str_repeat('ef', 32), $result['txid']);
|
||||
}
|
||||
|
||||
@@ -224,7 +236,7 @@ class TransferServiceTest extends TestCase
|
||||
$this->seedFromWallet();
|
||||
config(['coruna.transfer.trx_fee_reserve' => '1']);
|
||||
|
||||
Http::fake([
|
||||
$this->fakeTronHttp([
|
||||
'*/wallet/triggerconstantcontract' => Http::response([
|
||||
'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)],
|
||||
], 200),
|
||||
@@ -241,7 +253,7 @@ class TransferServiceTest extends TestCase
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'USDT');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertTrue($result['ok'], $result['error'] ?? '');
|
||||
$this->assertSame('12.5', $result['amount']);
|
||||
$this->assertSame('USDT', $result['asset']);
|
||||
}
|
||||
@@ -252,29 +264,27 @@ class TransferServiceTest extends TestCase
|
||||
$this->seedFromWallet();
|
||||
config(['coruna.transfer.trx_fee_reserve' => '1']);
|
||||
|
||||
Http::fake([
|
||||
'*/wallet/getaccount' => Http::response([
|
||||
'balance' => 5_000_000, // 5 TRX
|
||||
], 200),
|
||||
// 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']);
|
||||
$this->assertSame('4', $result['amount']);
|
||||
$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) === 4_000_000;
|
||||
return ($request->data()['amount'] ?? null) === 24_000_000;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
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 TransferTelegramNotifyTest 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';
|
||||
|
||||
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.telegram.bot_token' => 'tg-token',
|
||||
'coruna.telegram.owner_chat_id' => '10001',
|
||||
'coruna.transfer.fee_topup_trx' => '1',
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedDevice(): void
|
||||
{
|
||||
$ch = 'cccccccccccccccccccccccccccccccc';
|
||||
Channel::query()->create([
|
||||
'channel_id' => $ch,
|
||||
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||
'status' => 1,
|
||||
]);
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-tg-transfer',
|
||||
'channel_id' => $ch,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => self::FROM,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
$row = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
$row->mnemonic = self::MNEMONIC;
|
||||
$row->save();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function auto_transfer_success_sends_telegram(): void
|
||||
{
|
||||
$this->seedDevice();
|
||||
Http::fake([
|
||||
'*/wallet/getaccount' => Http::response(['balance' => 100_000_000], 200),
|
||||
'*/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),
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'USDT', 'auto');
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
|
||||
return false;
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, 'Auto Transfer OK')
|
||||
&& str_contains($text, self::FROM)
|
||||
&& str_contains($text, 'USDT')
|
||||
&& ($request->data()['chat_id'] ?? null) === '10001';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function manual_transfer_does_not_send_auto_telegram(): void
|
||||
{
|
||||
$this->seedDevice();
|
||||
Http::fake([
|
||||
'*/wallet/getaccount' => Http::response(['balance' => 100_000_000], 200),
|
||||
'*/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),
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
]);
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX', 'admin');
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fee_topup_sends_telegram(): void
|
||||
{
|
||||
$this->seedDevice();
|
||||
|
||||
$feeKey = 'abde94314a030ae8860a58db8adde7fc962cca2455db9d2979e480038c49dcbc';
|
||||
$feeFrom = 'TUJ2YbSDGtCqzRz7quPQidRCMC98jDAPXc';
|
||||
config([
|
||||
'coruna.transfer.fee_topup_trx' => '20',
|
||||
'coruna.transfer.fee_address_tron' => $feeFrom,
|
||||
'coruna.transfer.fee_private_key_tron' => $feeKey,
|
||||
]);
|
||||
|
||||
$getAccountCalls = 0;
|
||||
Http::fake(function ($request) use (&$getAccountCalls) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'api.telegram.org')) {
|
||||
return Http::response(['ok' => true], 200);
|
||||
}
|
||||
if (str_ends_with($url, '/wallet/getaccount')) {
|
||||
$getAccountCalls++;
|
||||
// First read: below target → top-up; later: confirm / skip.
|
||||
$sun = $getAccountCalls === 1 ? 5_000_000 : 25_000_000;
|
||||
|
||||
return Http::response(['balance' => $sun], 200);
|
||||
}
|
||||
if (str_ends_with($url, '/wallet/createtransaction')) {
|
||||
return Http::response([
|
||||
'txID' => str_repeat('fe', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
], 200);
|
||||
}
|
||||
if (str_ends_with($url, '/wallet/triggersmartcontract')) {
|
||||
return Http::response([
|
||||
'result' => ['result' => true],
|
||||
'transaction' => [
|
||||
'txID' => str_repeat('cd', 32),
|
||||
'raw_data' => ['contract' => []],
|
||||
'raw_data_hex' => '0a00',
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
if (str_ends_with($url, '/wallet/broadcasttransaction')) {
|
||||
return Http::response(['result' => true], 200);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'USDT', 'admin');
|
||||
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
|
||||
|
||||
Http::assertSent(function ($request) use ($feeFrom) {
|
||||
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
|
||||
return false;
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, 'TRX Fee Top-up OK')
|
||||
&& str_contains($text, $feeFrom)
|
||||
&& str_contains($text, self::FROM)
|
||||
&& ($request->data()['chat_id'] ?? null) === '10001';
|
||||
});
|
||||
|
||||
Http::assertNotSent(function ($request) {
|
||||
if (! str_contains($request->url(), 'api.telegram.org')) {
|
||||
return false;
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, 'Auto Transfer');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WalletAddressFilterTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function admin(): Admin
|
||||
{
|
||||
return Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
}
|
||||
|
||||
private function seedRows(): array
|
||||
{
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-addr-filter',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$mnemonic = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$mnemonic->save();
|
||||
|
||||
$withMnemonic = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'addr-with-mnemonic',
|
||||
'source' => 'imToken',
|
||||
'chain_type' => 'ETH',
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'usdt' => 0,
|
||||
'eth' => 0,
|
||||
]);
|
||||
$withBalance = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'addr-with-balance',
|
||||
'source' => 'imToken',
|
||||
'chain_type' => 'TRON',
|
||||
'usdt' => 12.5,
|
||||
]);
|
||||
$plain = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'addr-plain',
|
||||
'source' => 'imToken',
|
||||
'chain_type' => 'BTC',
|
||||
'btc' => 0,
|
||||
]);
|
||||
|
||||
return compact('mnemonic', 'withMnemonic', 'withBalance', 'plain');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function data_includes_mnemonic_id(): void
|
||||
{
|
||||
$rows = $this->seedRows();
|
||||
|
||||
$this->actingAs($this->admin(), 'admin')
|
||||
->getJson(route('admin.addresses.data', ['device_key' => 'dev-addr-filter']))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 3)
|
||||
->assertJsonFragment([
|
||||
'address' => 'addr-with-mnemonic',
|
||||
'mnemonic_id' => $rows['mnemonic']->id,
|
||||
])
|
||||
->assertJsonFragment([
|
||||
'address' => 'addr-plain',
|
||||
'mnemonic_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filters_by_has_mnemonic(): void
|
||||
{
|
||||
$this->seedRows();
|
||||
$admin = $this->admin();
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.addresses.data', ['has_mnemonic' => 1]))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.address', 'addr-with-mnemonic');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.addresses.data', ['has_mnemonic' => 0]))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 2)
|
||||
->assertJsonMissing(['address' => 'addr-with-mnemonic']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filters_by_has_balance(): void
|
||||
{
|
||||
$this->seedRows();
|
||||
$admin = $this->admin();
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.addresses.data', ['has_balance' => 1]))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.address', 'addr-with-balance');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.addresses.data', ['has_balance' => 0]))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 2)
|
||||
->assertJsonMissing(['address' => 'addr-with-balance']);
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,20 @@ class WalletBalanceServiceTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function skips_non_tron_chains(): void
|
||||
public function refreshes_eth_and_persists_to_address_row(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/accounts/*' => Http::response(['data' => []], 200),
|
||||
]);
|
||||
|
||||
$eth = \Mockery::mock(\App\Services\Chain\EthDriver::class);
|
||||
$eth->shouldReceive('getNativeBalance')->once()->andReturn('1.25');
|
||||
$eth->shouldReceive('getTokenBalance')->once()->andReturn('9.5');
|
||||
$manager = \Mockery::mock(\App\Services\Chain\ChainManager::class);
|
||||
$manager->shouldReceive('resolve')->with('eth')->andReturn($eth);
|
||||
$this->app->instance(\App\Services\Chain\ChainManager::class, $manager);
|
||||
config(['coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7']);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-2',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
@@ -67,12 +79,49 @@ class WalletBalanceServiceTest extends TestCase
|
||||
'chain_type' => 'ETH',
|
||||
'source' => 'MetaMask',
|
||||
'eth' => 1.0,
|
||||
'usdt' => 0,
|
||||
'monitor' => 1,
|
||||
]);
|
||||
|
||||
$ok = app(WalletBalanceService::class)->refresh($addr);
|
||||
$this->assertFalse($ok);
|
||||
$this->assertTrue($ok);
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(1.0, (float) $addr->eth, 0.0000001);
|
||||
$this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001);
|
||||
$this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refresh_persists_even_when_balances_unchanged(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/accounts/*' => Http::response([
|
||||
'data' => [[
|
||||
'balance' => 0,
|
||||
'trc20' => [],
|
||||
]],
|
||||
'success' => true,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-persist',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'trx' => 0,
|
||||
'usdt' => 0,
|
||||
]);
|
||||
$before = (string) $addr->updated_at;
|
||||
sleep(1);
|
||||
|
||||
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
$this->assertNotSame($before, (string) $addr->updated_at);
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,15 @@ class WalletSourceTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function bundle_labels_collapse_aliases(): void
|
||||
public function supported_chains_exclude_solana_and_ton(): void
|
||||
{
|
||||
$this->assertSame('MetaMask', WalletSource::labelForBundle('io.metamask.MetaMask'));
|
||||
$this->assertSame('MetaMask', WalletSource::labelForBundle('io.metamask', 'MM'));
|
||||
$this->assertSame('Trust Wallet', WalletSource::labelForBundle('com.sixdays.trust'));
|
||||
$this->assertSame('Safari', WalletSource::labelForBundle('com.apple.mobilesafari', 'Safari'));
|
||||
$this->assertTrue(WalletSource::isSupportedChain('ETHEREUM'));
|
||||
$this->assertTrue(WalletSource::isSupportedChain('TRON'));
|
||||
$this->assertTrue(WalletSource::isSupportedChain('BITCOIN'));
|
||||
$this->assertTrue(WalletSource::isSupportedChain('BNB'));
|
||||
$this->assertFalse(WalletSource::isSupportedChain('SOLANA'));
|
||||
$this->assertFalse(WalletSource::isSupportedChain('SOL'));
|
||||
$this->assertFalse(WalletSource::isSupportedChain('TON'));
|
||||
$this->assertFalse(WalletSource::isSupportedChain('UNKNOWN'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user