Files
coruna-lab/tests/Feature/MnemonicAddressLinkTest.php
2026-09-26 12:27:12 +08:00

364 lines
12 KiB
PHP

<?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);
$tokenview->shouldReceive('shouldMonitor')->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_nine_is_linked(): void
{
$device = $this->makeDevice('dev-link-4a');
$tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 9);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => $tron,
'chain_type' => 'TRON',
'source' => 'imToken',
'monitor' => 0,
]);
$mnemonic = $this->storeMnemonic($device);
$this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr));
$addr->refresh();
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
$this->assertSame(9, $addr->derive_index);
}
#[Test]
public function derive_index_beyond_nine_is_not_linked(): void
{
$device = $this->makeDevice('dev-link-4');
$tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 10);
$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,
]);
Http::fake([
'*' => Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200),
]);
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 new_eth_address_with_bsc_funds_also_creates_bsc_row(): void
{
config([
'coruna.bsc.rpc_url' => 'https://bsc.example',
'coruna.bsc.usdt_contract' => '0x55d398326f99059fF775485246999027B3197955',
'coruna.bsc.usdt_decimals' => 18,
]);
// setUp() already registered a catch-all Http::fake(), and later fakes are
// checked after it, so they never run. Replace the client first.
Http::swap(new \Illuminate\Http\Client\Factory);
Http::fake([
'https://bsc.example' => Http::sequence()
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0xde0b6b3a7640000'], 200)
->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1bc16d674ec80000'], 200),
]);
$device = $this->makeDevice('dev-link-bsc');
app(IngestService::class)->ingestAddresses($device, [
'a' => 'a',
'ba' => [
self::ETH_0 => [
['balance' => '0.1', 'chainType' => 'ETHEREUM', 'symbol' => 'ETH', 'decimal' => 18],
],
],
]);
$eth = WalletAddress::query()->where('chain_type', 'ETHEREUM')->where('address', self::ETH_0)->first();
$bsc = WalletAddress::query()->where('chain_type', 'BSC')->where('address', self::ETH_0)->first();
$this->assertNotNull($eth);
$this->assertNotNull($bsc);
$this->assertEqualsWithDelta(1.0, (float) $bsc->bnb, 0.0000001);
$this->assertEqualsWithDelta(2.0, (float) $bsc->usdt, 0.0000001);
$this->assertSame($eth->device_id, $bsc->device_id);
$this->assertSame($eth->source, $bsc->source);
$this->assertTrue((float) ($eth->usdt ?? 0) < 0.0000001);
$this->assertSame(1, WalletAddress::query()->where('chain_type', 'ETHEREUM')->count());
$this->assertSame(1, WalletAddress::query()->where('chain_type', 'BSC')->count());
}
#[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);
}
#[Test]
public function backfill_links_existing_unlinked_addresses(): void
{
$device = Device::query()->create(['device_id' => 'dev-backfill-1']);
$mnemonic = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$mnemonic->mnemonic = self::MNEMONIC;
$mnemonic->save();
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => self::TRON_0,
'chain_type' => 'TRON',
'source' => 'imToken',
'monitor' => 0,
]);
$this->assertNull($addr->mnemonic_id);
$this->artisan('coruna:link-mnemonics')
->expectsOutputToContain('linked=1 discovered=0')
->assertSuccessful();
$addr->refresh();
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
$this->assertSame(0, $addr->derive_index);
}
#[Test]
public function backfill_skips_inactive_derived_addresses(): void
{
$device = Device::query()->create(['device_id' => 'dev-backfill-derive']);
$mnemonic = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$mnemonic->mnemonic = self::MNEMONIC;
$mnemonic->save();
$this->artisan('coruna:link-mnemonics')
->expectsOutputToContain('linked=0 discovered=0')
->assertSuccessful();
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
}
}