Files
coruna-lab/app/Services/MnemonicDetector.php
T
2026-09-12 23:53:53 +08:00

270 lines
7.8 KiB
PHP

<?php
namespace App\Services;
use App\Services\Chain\Bip44;
class MnemonicDetector
{
/** @var list<int> */
public const LENGTHS = [12, 15, 18, 21, 24];
public const MIN_SUSPECTED = 8;
public const MIN_CONFIRMED = 12;
public const MIN_UNIQUE = 8;
/**
* Hardware-wallet chrome that is also on the BIP39 list. Only used to
* break ties when two sliding windows both checksum.
*
* @var array<string, true>
*/
private const UI_PREFIX = [
'card' => true,
'key' => true,
'wallet' => true,
'seed' => true,
'word' => true,
'phrase' => true,
'recovery' => true,
'secret' => true,
'hardware' => true,
'device' => true,
'account' => true,
];
/** @var array<string, true>|null */
private static ?array $english = null;
/**
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
public function detect(string $text, bool $strictGaps = false): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
$tokens = $strictGaps ? $this->noteTokens($text) : $this->englishTokens($text);
$wordlist = $this->englishSet();
$real = array_values(array_filter($tokens, fn ($word) => $word !== ''));
if (count($real) < self::MIN_SUSPECTED) {
return null;
}
$perfect = $this->perfectPhrase($tokens, $wordlist);
if ($perfect !== null) {
return $perfect;
}
if ($strictGaps) {
return null;
}
$partial = $this->suspectedWindow($tokens, $wordlist);
return $partial !== null && $this->uniqueListWords($partial['candidate']) >= self::MIN_UNIQUE
? $partial
: null;
}
public function uniqueListWords(string $candidate): int
{
$wordlist = $this->englishSet();
$uniq = [];
foreach (preg_split('/\s+/', strtolower(trim($candidate))) ?: [] as $word) {
if (isset($wordlist[$word])) {
$uniq[$word] = true;
}
}
return count($uniq);
}
/**
* @param list<string> $tokens
* @param array<string, true> $wordlist
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function perfectPhrase(array $tokens, array $wordlist): ?array
{
$run = [];
$best = null;
$n = count($tokens);
for ($i = 0; $i <= $n; $i++) {
$word = $tokens[$i] ?? null;
if ($word !== null && $word !== '' && isset($wordlist[$word])) {
$run[] = $word;
continue;
}
$hit = $this->confirmedFromRun($run);
if ($hit !== null && ($best === null || $hit['word_count'] > $best['word_count'])) {
$best = $hit;
}
$run = [];
}
return $best;
}
/**
* @param list<string> $run
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function confirmedFromRun(array $run): ?array
{
$n = count($run);
$fallback = null;
foreach (array_reverse(self::LENGTHS) as $len) {
if ($n < $len) {
continue;
}
$usable = [];
for ($offset = 0; $offset <= $n - $len; $offset++) {
$hit = $this->hitFromWords(array_slice($run, $offset, $len), true);
$fallback ??= $hit;
if (Bip44::isUsableMnemonic($hit['candidate'])) {
$usable[] = $hit;
}
}
if ($usable !== []) {
return $this->preferNonUiPrefix($usable);
}
}
return $fallback;
}
/**
* @param list<string> $words
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
*/
private function hitFromWords(array $words, bool $confirmed): array
{
$len = count($words);
return [
'candidate' => implode(' ', $words),
'word_count' => $len,
'score' => $len * 10,
'language' => 'en',
'confirmed' => $confirmed,
];
}
/**
* @param non-empty-list<array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}> $hits
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
*/
private function preferNonUiPrefix(array $hits): array
{
foreach ($hits as $hit) {
$first = explode(' ', $hit['candidate'])[0] ?? '';
if (! isset(self::UI_PREFIX[$first])) {
return $hit;
}
}
return $hits[0];
}
/**
* @param list<string> $tokens
* @param array<string, true> $wordlist
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
*/
private function suspectedWindow(array $tokens, array $wordlist): ?array
{
$n = count($tokens);
$best = null;
$maxLen = min(24, $n);
for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) {
for ($i = 0; $i <= $n - $len; $i++) {
$window = array_slice($tokens, $i, $len);
$hits = 0;
foreach ($window as $word) {
if ($word !== '' && isset($wordlist[$word])) {
$hits++;
}
}
if ($hits < self::MIN_SUSPECTED) {
continue;
}
$score = ($hits * 10) - (($len - $hits) * 2);
if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) {
continue;
}
$best = [
'candidate' => implode(' ', $window),
'word_count' => $hits,
'score' => $score,
'language' => 'en',
'confirmed' => false,
];
}
}
return $best;
}
/** @return list<string> */
private function englishTokens(string $text): array
{
preg_match_all('/[a-z]{3,8}/', strtolower($text), $m);
return $m[0] ?? [];
}
/**
* Keep list indices (1. 2. 12) from breaking a phrase, but digits/units
* like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words.
*
* @return list<string>
*/
private function noteTokens(string $text): array
{
$out = [];
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
$word = trim($raw, ".,!?;:\"'()[]");
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
$out[] = $word;
continue;
}
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
continue;
}
$out[] = '';
}
return $out;
}
/** @return array<string, true> */
private function englishSet(): array
{
return self::$english ??= $this->loadSet();
}
/** @return array<string, true> */
private function loadSet(): array
{
$set = [];
$path = resource_path('bip39/english.txt');
if (! is_file($path)) {
return $set;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$word = strtolower(trim($line));
if ($word !== '') {
$set[$word] = true;
}
}
return $set;
}
}