简介:面向 ImToken 钱包用户与数字资产备份学习者的英文助记词查询文档,围绕助记词记录、校对与恢复备份场景整理,帮助读者核对单词拼写、理解12至24词助记词的组成方式及安全保存要点。压缩包内共1个doc文件,大小约166KB,内容以2048个英文助记词的有序汇录为主,适合在创建钱包、抄写备份或恢复前做快速比对,减少因拼写偏差带来的操作风险。已有11730人学习,说明该主题具有较高查询需求。文档还便于按字母顺序查找相近词形,配合助记词管理、泄露防范与定期备份等知识点,可作为钱包安全自查的轻量参考;需注意助记词属于敏感信息,查询与保管应在离线、可信环境中进行。
1. 2048 个单词的助记词词表:抄错一个字母,恢复出来就是空钱包
一位同事把纸质备份上的wall抄成了walk,十二个词一个不多一个不少,导入钱包后地址却是空的——BIP-39 助记词词表只有 2048 个单词,校验和只有 4 位,容错空间比大多数人想象的小得多。这份资源就是那张完整的 2048 词英文表,配合前缀索引、编辑距离和校验和验证,能把「手抄转录—拼写漂移—批量校对」这条链路做成离线可跑的工具。它适合两类人:需要把纸质备份数字化归档、又不想把助记词粘进任何网页的运维型用户;以及想搞清楚 BIP-39 编码到底怎么算、checksum 到底管多大用的开发者。下面按「词表结构 → 本地索引 → 校验流程 → 踩坑 → 进阶」拆开讲,代码全部离线执行。
2. BIP-39 词表的四层结构:2048、11 位与藏在尾部的校验和
2.1 2048 = 2^11,一个单词正好装 11 位熵
词表长度被定死在 2048,不是随手取的整数,而是 2 的 11 次方。这意味着每个单词可以无损映射成 11 个二进制位,12 个单词就是 132 位,24 个单词就是 264 位,都是 11 的整数倍,拼接时不会出现半位对齐问题。词表本身按字母序排列,索引从 0 的abandon一直到 2047 的zoo,索引值就是编码值,换成别的语言词表时索引仍然对齐,这也是多语言助记词能互相转换的基础。
理解这一点之后,很多看起来玄学的现象就有解释了:为什么词表不能随便加词、为什么不能用自定义单词表、为什么某些「精简版词表」生成的助记词在标准钱包里导入失败。索引一变,整串比特流全变,派生出来的种子自然对不上。所以拿到的词表文件第一件事不是查词,而是断言它正好 2048 行、顺序与标准一致、没有 BOM、没有重复项。
2.2 校验和不是纠错码,它只负责「拦下来」
BIP-39 的校验和规则是:取熵长度 ENT,计算 SHA-256,取结果的前 ENT/32 位作为校验位 CS,拼在熵后面,总长度 ENT+CS 必须是 11 的倍数。128 位熵配 4 位校验得到 132 位即 12 词,256 位熵配 8 位校验得到 264 位即 24 词。也就是说 12 词助记词的校验空间只有 16 种,随机写错一个词,仍然有约 1/16 的概率「碰巧通过」校验。
这是最容易翻车的地方。很多人校验通过就以为备份无误,实际上校验和只能证明「这串词是一组合法编码」,不能证明「这就是你当初那组词」。真正可靠的做法是双通道:一边用校验和筛掉明显错误的组合,一边用派生出的地址或公钥指纹与自己记录的历史地址比对。前者是语法检查,后者才是语义确认,两者缺一不可。
2.3 词表是编码表,不是钱包文件
BIP-39 只负责「熵 ↔ 单词」这一层编码,从种子到账户再到地址是 BIP-32/BIP-44 的事。常见误解是把助记词当成「加密后的私钥文件」,以为复制一份就能用;实际上它是种子的可读形式,任何拿到这 12 到 24 个词的人都能在任意兼容钱包里完整重建资产,不需要密码,不需要原设备。词表资源的价值在于「校对与查询」,不改变这个安全模型。
因此工具设计上要有一条硬边界:所有计算在本地内存里完成,进程退出即销毁,不写日志、不落临时文件、不发网络请求。脚本里连requests都不该出现,任何「上传助记词帮你检测」的服务都应默认视为高风险。这条约束会直接影响后面的代码结构——词表从本地文件读,校验结果只打印到标准输出。
2.4 一份可用的本地词表应该长什么样
标准英文词表是纯文本,一行一个单词,UTF-8 无 BOM,末尾可以有换行,不能有空行和行内空格。大小写全部小写,没有重音符号。有些下载来的版本会带序号前缀(1 abandon)或者用逗号分隔,这类文件必须先清洗再断言,否则按行号当下标的代码会整体错位。
| 检查项 | 期望值 | 出错后果 |
|---|---|---|
| 行数 | 恰好 2048 | 索引越界或映射错位 |
| 排序 | 严格字母序 | 前缀索引失效,查词结果错乱 |
| 编码 | UTF-8 无 BOM | 首词带不可见字符,匹配失败 |
| 重复 | 无重复项 | 反向索引被覆盖,指向错误下标 |
| 空白 | 无空行、无行内空格 | 词条数虚高,断言不通过 |
把这张表当成硬性验收清单,比事后 debug 便宜得多。下面所有脚本都建立在「词表已通过这五项检查」的前提上。
3. 把词表做成可查询的本地索引:前缀、编辑距离与反向映射
3.1 读入与断言:先把脏数据挡在门外
第一步永远是加载加验收,而不是直接dict一把梭。断言失败就立刻退出,不要让错误数据流进后面的校验环节。
# wordlist.py —— 词表加载与基础验收,全程离线 import unicodedata from pathlib import Path def load_wordlist(path="bip39_english.txt"): raw = Path(path).read_text(encoding="utf-8-sig") # utf-8-sig 顺手吃掉 BOM words = [] for line in raw.splitlines(): line = unicodedata.normalize("NFKD", line).strip() if not line: # 跳过空行,不跳过非空脏行 continue if " " in line or "," in line: # 带序号或分隔符的版本直接拒绝 raise ValueError(f"词表行含分隔符: {line!r}") words.append(line.lower()) assert len(words) == 2048, f"行数应为 2048,实际 {len(words)}" assert len(set(words)) == 2048, "存在重复词条" assert words == sorted(words), "词表未按字母序排列" return wordsencoding="utf-8-sig"处理带 BOM 的 Windows 导出文件,比事后lstrip干净。NFKD规范化是为了对付从文档复制时混进来的兼容字符,英文词表本来没有重音,但粘贴来源不可控。三个断言分别对应行数、去重、排序,任一失败就抛异常,调用方不需要做二次判断。
3.2 前缀索引:4 个字母足以定位
BIP-39 英文词表在设计上保证前 4 个字母唯一,也就是说不存在两个词共享同一个 4 字母前缀。这给了一个非常廉价的查询路径:用户只需要记住前 4 个字母,就能唯一定位到单词。对纸质备份模糊、只认出半个单词的场景,这个特性比模糊搜索有用得多。
# index.py —— 前缀索引与反向映射 from collections import defaultdict def build_index(words): forward = {w: i for i, w in enumerate(words)} # 词 -> 11 位索引值 prefix = defaultdict(list) for w in words: prefix[w[:4]].append(w) # 前 4 字母 -> 候选词 return forward, prefix def resolve(prefix_map, frag): frag = frag.strip().lower() if len(frag) >= 4: return prefix_map.get(frag[:4], []) # 少于 4 字母时退化为线性扫描,词表只有 2048 条,代价可接受 return [w for k in prefix_map for w in prefix_map[k] if w.startswith(frag)]forward是后面编码校验要用的反向表,prefix承担人机交互层的查询。小于 4 个字母时不做前缀树,直接扫,2048 条数据量下耗时在微秒级,没必要为了「优雅」引入 trie 增加维护成本。这也是我一般会做的取舍:数据规模没到十万级,别急着上复杂结构。
3.3 编辑距离:给手抄错误排个候选序
真实场景里更多是「写错一个字母」而不是「只记得前缀」。像good和gold、wall和walk这类只差一个字符的词对,在词表里是真实存在的,所以编辑距离只能用来生成候选,不能用来直接下结论。
# fuzzy.py —— 限制编辑距离的候选生成 def edit_distance(a, b, limit=2): if abs(len(a) - len(b)) > limit: return limit + 1 prev = list(range(len(b) + 1)) for i, ca in enumerate(a, 1): cur = [i] for j, cb in enumerate(b, 1): cost = 0 if ca == cb else 1 cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)) if min(cur) > limit: # 整行都超限,提前剪枝 return limit + 1 prev = cur return prev[-1] def candidates(word, words, limit=1): return [w for w in words if edit_distance(word, w, limit) <= limit]limit默认给 1,是因为距离 2 的候选在 2048 词表里会明显变多,人工二次确认的成本反而上升。上面的实现是标准 Levenshtein,没做换位(Damerau)扩展——手抄场景里字母顺序写反的比例远低于写错字母,加上换位会让候选翻倍,收益不成正比。如果确实需要,把prev[j - 2] + cost那一路补进min即可。
3.4 反向映射与索引值的位宽
forward表把词映射到 0–2047 的整数,这个整数在拼比特流时必须格式化成 11 位定长。少写一位就是灾难性的:format(0, "b")得到"0",拼进比特流会整体错位,校验和必然失败,但报错信息不会告诉你错在哪。
def word_to_bits(word, forward): if word not in forward: raise KeyError(f"不在词表中: {word}") return format(forward[word], "011b") # 必须补齐到 11 位"011b"里的0是填充字符,11是宽度,b是二进制。这是后面所有校验逻辑的地基,写错这一行,后面全白搭。我见过有人用bin(idx)[2:].zfill(11),效果一样但多一次字符串切分,在需要处理上万组样本的回归测试里差距会被放大。
4. 校验流程落地:从十二个单词到一次可信的比对
4.1 先把输入洗干净:大小写、标点、序号、全角空格
用户交上来的助记词格式千奇百怪:带序号的1. abandon、带中文顿号的abandon、ability、全角空格分隔、从 PDF 复制带软换行、手机输入法把l打成1。清洗阶段要做的是「归一化但不猜测」,把确定可以丢的东西丢掉,把不确定的东西留给校验和去判断。
import re def normalize(text): text = text.replace("\u3000", " ") # 全角空格 text = re.sub(r"[0-9]+[.、))]?\s*", " ", text) # 去掉行首序号 text = re.sub(r"[^a-zA-Z\s]", " ", text) # 非字母一律视为分隔 return [w for w in text.lower().split() if w] def check_shape(words): if len(words) not in (12, 15, 18, 21, 24): raise ValueError(f"词数 {len(words)} 不合法,只能是 12/15/18/21/24") return len(words)normalize里那条[^a-zA-Z\s]是关键:它把逗号、顿号、换行、制表符统统变成空格,再用split()收口。这样做的好处是容忍各种分隔符,坏处是把「连写」(比如abandonability)也当成一个 token 留着——连写要单独处理,放到最后一章讲。check_shape只做长度判断,不做别的,职责单一,出错信息也明确。
4.2 校验和验证:从比特流回到熵,再算一次 SHA-256
核心逻辑不长,但每一行都要对得上标准。先拼比特流,再按词数推出校验位长度,切出熵,算 SHA-256,取首字节按位比对。
import hashlib def verify(words, forward): n = len(words) bits = "".join(word_to_bits(w, forward) for w in words) # 12 词 -> 132 位 cs_len = n * 11 // 33 # 12->4, 15->5, 18->6, 21->7, 24->8 ent_len = n * 11 - cs_len entropy = int(bits[:ent_len], 2).to_bytes(ent_len // 8, "big") digest = hashlib.sha256(entropy).digest() expect = format(digest[0], "08b")[:cs_len] actual = bits[ent_len:] return actual == expect, {"entropy_bits": ent_len, "checksum_bits": cs_len}n * 11 // 33这个式子是校验位长度的紧凑写法,12 词得 4、24 词得 8,比自己写{12:4, 24:8}的字典更不容易漏项。format(digest[0], "08b")[:cs_len]只取首字节的二进制串再截断,因为 CS 最大就是 8 位,不用算整个 32 字节。返回值里带上位数信息,方便在批量校对时输出统计,排查「到底错在词表还是错在长度」。
需要重复强调:verify返回True只代表「这是一组合法编码」,不代表「这是你的那组」。要确认归属,还得派生种子、算地址、跟你记录的历史地址比。这一步不要省。
4.3 派生种子:PBKDF2 的两个参数别改
从助记词到种子是 BIP-39 的第二段:PBKDF2-HMAC-SHA512,迭代 2048 轮,盐是字符串"mnemonic"拼上可选的 passphrase,输出 64 字节。
import unicodedata, hashlib def to_seed(words, passphrase=""): mnemonic = unicodedata.normalize("NFKD", " ".join(words)) salt = unicodedata.normalize("NFKD", "mnemonic" + passphrase) return hashlib.pbkdf2_hmac( "sha512", mnemonic.encode("utf-8"), salt.encode("utf-8"), 2048, # 轮数写死,改动即不兼容标准 dklen=64, )三个参数里,2048和dklen=64是标准硬约束,改了就跟所有钱包对不上。passphrase是唯一的自由变量:为空时盐就是"mnemonic",非空时盐是"mnemonic" + passphrase,且 passphrase 不带分隔符、不做长度限制、大小写敏感。很多人恢复失败就栽在这里——当初随手填了个「空格 + 生日」,恢复时忘了那个空格。NFKD 规范化同样不能省,带重音字符的 passphrase 不做规范化,算出来的种子在不同实现间会不一致。
4.4 端到端跑一遍:两条用例覆盖 12 词与 24 词
把上面的模块串成一个可以照抄的命令行入口,用两条已知向量自测,确认实现没跑偏。
# 12 词用例:128 位熵 + 4 位校验 python check.py --words "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" # 24 词用例:256 位熵 + 8 位校验 python check.py --file backup.txt --out fingerprint.txt# check.py 的核心分支 import argparse def main(): ap = argparse.ArgumentParser() ap.add_argument("--words", help="空格分隔的助记词,仅用于自测") ap.add_argument("--file", help="从文件读,一行词或整段文本") ap.add_argument("--list", default="bip39_english.txt") args = ap.parse_args() words_list = load_wordlist(args.list) forward, prefix = build_index(words_list) text = args.words if args.words else Path(args.file).read_text(encoding="utf-8") words = normalize(text) check_shape(words) ok, meta = verify(words, forward) print({"valid": ok, **meta}) # 只打印结论与位数,不打印助记词本身输出里刻意不回显助记词,只给布尔值和位数统计,避免终端历史、日志采集、截图把这些内容带出去。--words参数只用于公开测试向量自测,真实数据一律走--file并从加密盘读取。第一条命令的预期是valid: True,因为它就是标准里的全abandon测试向量;如果这条都过不了,问题一定在词表加载或位宽格式化的环节,不用往下猜。
5. 助记词校对的五个坑:现象、原因与处理办法
5.1 校验通过了,地址却不对
现象:脚本返回valid: True,导入钱包后地址与历史记录不一致,余额为零。原因:12 词只有 4 位校验,随机错一处仍有约 1/16 概率通过;或者 passphrase 与当初不一致,种子完全不同。解决:把「校验和通过」降级为初筛,必须再派生一次种子、算出第一个账户地址,与纸质记录或历史收款地址比对。地址对上了才算真正确认。这一条我踩过,之后再没省过。
5.2 词表文件里混进了序号,索引整体偏移
现象:明明每个单词都认识,校验却一直失败,换个词又「碰巧」通过。原因:下载的词表带1 abandon这类前缀,按行号当下标时整体偏移一位,2047 号词被映射成 0。解决:加载阶段就断言行数与排序,遇到分隔符直接抛异常,别做「智能清洗」。词表是标准件,格式不对就换一份,不要将就。
5.3 全角空格与输入法自动纠正
现象:用户在手机上手抄,用中文输入法打空格,粘进脚本后split()只切出一个超长 token。原因:全角空格\u3000不在 ASCII 空白集合里,Python 的split()不带参数时能处理部分 Unicode 空白,但正则\s在某些实现下行为不一致。解决:归一化时先replace("\u3000", " ")显式替换,再用[^a-zA-Z\s]收口,两层兜底。另外提醒用户关掉输入法的「自动首字母大写」和「拼写纠正」,这两个功能会悄悄改词。
5.4 只记得前三个字母就开始猜
现象:查词工具返回一堆候选,人工挑选时选错,越校越乱。原因:BIP-39 只保证前 4 字母唯一,3 字母前缀下候选可能有多个,靠人眼挑等于赌博。解决:交互层面强制要求输入满 4 个字母再出候选;不足 4 位时列出全部候选并标注索引值,让人对着纸质备份核对,而不是凭印象选。工具不该替用户做「最像」的判断。
5.5 把助记词粘进在线工具
现象:为了省事用了某个网页版「助记词检测」,几周后资产被转走。原因:助记词等价于私钥,任何接收它的服务都等于拿到了全部资产控制权,页面关掉不代表数据没被留存。解决:整条链路离线。词表放本地、脚本放本地、输出只留派生地址的指纹、跑完清 shell 历史。真要跨设备,用离线介质传脚本和词表,别用即时通讯工具传助记词本身。
6. 连写备份的补救:用动态规划切词,再用校验和收口
纸质备份最常见的形态不是空格分隔,而是一整段连写:abandonabilityableabout...。人眼切词靠语感,脚本切词要靠搜索。2048 个词最长 8 个字母,最短 3 个,一条 132 字符以内的连写串,用记忆化搜索枚举所有可行切分,规模完全可控——前提是必须用校验和做剪枝,否则组合数会爆炸。
思路是把「切分位置」当成状态,从右往左推,每一步尝试长度 3 到 8 的子串,命中词表就继续。命中不等于正确,所以每凑满 12 个词就立刻跑一次verify,通过才收进结果集。
# segment.py —— 连写串切分,仅用于恢复自己的备份 from functools import lru_cache def segment(text, words, forward, target_len=12, max_len=8, min_len=3): wordset = set(words) results = [] def dfs(pos, picked): if len(results) >= 5: # 只留少量候选,避免刷屏 return if pos == len(text): if len(picked) == target_len: ok, _ = verify(picked, forward) if ok: results.append(list(picked)) return if len(picked) >= target_len: return for size in range(min_len, max_len + 1): frag = text[pos:pos + size] if frag in wordset: picked.append(frag) dfs(pos + size, picked) picked.pop() dfs(0, []) return resultstarget_len按备份实际词数给 12 或 24,给错了永远搜不到。max_len=8是英文词表的最长词长,min_len=3是最短,写死比动态算更快也更安全。len(results) >= 5是熔断:校验和 4 位意味着理论上仍可能有多个切分同时通过,只保留前几个候选交给人对着原始纸质件核对,不要指望脚本给出唯一答案。
这套逻辑有两个明确的边界,越界就别用:一是只处理「空格丢失」,不处理「单词拼错」,拼错要先走第 3 章的编辑距离生成候选,再进切分,两层搜索叠起来复杂度不可控;二是只用于恢复自己或明确授权方的备份,把它包装成对他人助记词的猜测工具,性质就变了。
验证脚本本身是否可靠,方法也简单:拿标准测试向量造样本,做回归。用已知熵生成 200 组 12 词、200 组 24 词,随机注入一处替换、一处换位、一处缺词,跑一遍看检出率是否符合预期。
| 注入错误 | 期望行为 | 12 词实测关注点 | 24 词实测关注点 |
|---|---|---|---|
| 单词替换一个字母 | 编辑距离给出候选 | 候选数常为 1–2 | 同左 |
| 单词整体替换为另一合法词 | 校验和大概率拦截 | 约 1/16 漏检 | 约 1/256 漏检 |
| 两个词顺序颠倒 | 校验和拦截 | 基本必拦 | 基本必拦 |
| 少写一个词 | 长度检查直接拦 | 11 词非法 | 23 词非法 |
| 连写无空格 | 切分 + 校验和 | 候选数通常 1 | 候选可能 2–3 |
表格里 24 词那一列的漏检率明显更低,这是校验位从 4 涨到 8 的直接结果,也是我建议重要备份一律用 24 词的原因之一——多写十二个词,换来十六倍的错误拦截能力,很划算。
从那以后我每次做助记词归档,都强制走一遍「断言词表 → 归一化 → 校验和 → 派生地址比对」四步,哪怕只是把一张纸拍照存档。跳过任何一步,后面都可能是几小时的排查,甚至是不可逆的损失。希望帮到你。
本文还有配套的精品资源,点击获取