364 lines
12 KiB
PHP
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());
|
|
}
|
|
}
|