Кошелёк: ключи, адреса и мнемоники
Мы создавали ключи прямо в коде из массива байтов. Для настоящего кошелька так нельзя: 32 случайных байта невозможно запомнить и легко потерять. Сделаем кошелёк по-человечески: мнемоника из обычных слов, из которой детерминированно восстанавливается целое дерево ключей.
Идея
Сначала развеем главное заблуждение: кошелёк не хранит монеты. Монеты живут в состоянии цепи (балансы по адресам). Кошелёк хранит ключи, которыми ты доказываешь право этими монетами распоряжаться. Потеряешь ключ, монеты останутся в цепи, но станут навсегда недоступны: никто, включая тебя, не подпишет с них перевод.
Адрес у нас это отпечаток открытого ключа: первые 20 байт его SHA-256. Так адрес короче ключа и не раскрывает сам ключ до первой траты. Ethereum делает похоже, но не так же: там адрес это последние 20 байт Keccak-256 открытого ключа. Идея общая, детали разные: другой хеш и другой срез байтов.
impl Address {
/// Выводит адрес из открытого ключа: первые 20 байт хеша ключа.
pub fn from_public_key(pk: &PublicKey) -> Address {
let digest = Hash::of(&pk.0);
let mut out = [0u8; 20];
out.copy_from_slice(&digest.0[..20]);
Address(out)
}
}
Ключи и подпись
Подпись у нас Ed25519 (кривая ed25519 из урока про эллиптические кривые): детерминированная, быстрая, без ловушки повтора nonce, на которой горел ECDSA (урок про подписи). Bitcoin и Ethereum исторически берут secp256k1, его разберём в уроке про ключи и транзакции Bitcoin, а для своего узла Ed25519 строже и проще.
impl Keypair {
/// Строит пару из 32-байтового зерна. Зерно может прийти от ОС или из
/// HD-кошелька: одно зерно всегда даёт один и тот же ключ.
pub fn from_seed(seed: &[u8; 32]) -> Keypair {
Keypair { signing: SigningKey::from_bytes(seed) }
}
pub fn public_key(&self) -> PublicKey {
PublicKey(self.signing.verifying_key().to_bytes())
}
pub fn address(&self) -> Address {
Address::from_public_key(&self.public_key())
}
pub fn sign(&self, message: &[u8]) -> Signature {
Signature(self.signing.sign(message).to_bytes())
}
}
Закрытый ключ (signing) никогда не покидает кошелёк, наружу идут только открытый ключ и подписи. Это и есть вся асимметрия: одним ключом подписываю, другим любой проверяет.
Мнемоника BIP-39
Записать на бумагу 32 случайных байта в hex это рецепт катастрофы: одна описка, и кошелёк потерян. BIP-39 решает это: те же байты кодируются в список обычных слов, который человек спокойно перепишет. Последнее слово несёт контрольную сумму, поэтому описку чаще всего видно сразу.
Здесь в дело вступают три зависимости из Cargo.toml, который мы завели в первом уроке проекта: bip39 = { version = "2", features = ["rand"] } даёт мнемонику и словарь (фича rand нужна для генерации случайной фразы), а hmac = "0.12" и sha2 = "0.10" понадобятся ниже для вывода дочерних ключей.
use bip39::Mnemonic;
pub struct Wallet {
mnemonic: Mnemonic,
seed: [u8; 64],
}
impl Wallet {
/// Заводит новый кошелёк со случайной мнемоникой из 12 слов.
pub fn generate() -> Wallet {
let mnemonic = Mnemonic::generate(12).expect("12 слов это валидная длина");
Wallet::from_mnemonic(mnemonic)
}
/// Восстанавливает кошелёк из записанной фразы.
pub fn from_phrase(phrase: &str) -> Result<Wallet, bip39::Error> {
Ok(Wallet::from_mnemonic(Mnemonic::parse(phrase)?))
}
fn from_mnemonic(mnemonic: Mnemonic) -> Wallet {
let seed = mnemonic.to_seed(""); // без дополнительного пароля
Wallet { mnemonic, seed }
}
/// Мнемоника для записи на бумагу.
pub fn phrase(&self) -> String {
self.mnemonic.to_string()
}
}
Из мнемоники получается 64-байтовое зерно (seed): детерминированно, одна фраза всегда даёт одно зерно.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.