Script: стековый язык Bitcoin
В прошлом уроке мы всё время говорили «условие траты», не раскрывая, что это. Пора: условие это программа на маленьком языке Script, а «удовлетворить условие» значит запустить программу и получить на стеке true. Bitcoin с самого начала был программируемыми деньгами, просто язык нарочно сделан крохотным и безопасным.
Идея
Каждый выход несёт scriptPubKey, программу-замок. Каждый вход, который его тратит, несёт scriptSig, ключ к замку. Проверка траты это запуск обеих программ на общем стеке: если после исполнения на вершине лежит истина, трата разрешена.
Script это стековая машина, как Forth, только без циклов. Каждая операция снимает аргументы с вершины и кладёт результат. Отсутствие циклов не случайность: программа обязана завершаться за ограниченное число шагов, иначе один злой выход подвесил бы каждый проверяющий узел. Это осознанный отказ от полноты по Тьюрингу ради предсказуемости.
Ту же стековую машину ты уже видел: WASM в уроке 67 раздела Rust и увидишь снова в EVM в уроке 26. Меняется набор операций и правила, суть одна.
P2PKH по шагам
Возьмём самый частый скрипт, оплату по хешу ключа из прошлого урока. Замок и ключ выглядят так:
scriptPubKey (в выходе): OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG
scriptSig (во входе): <sig> <pubkey>
Исполнение идёт слева направо на одном стеке. Сначала данные из scriptSig ложатся на стек, потом операции из scriptPubKey их разбирают:
<sig>,<pubkey>кладут подпись и ключ на стек.OP_DUPдублирует ключ (он понадобится дважды).OP_HASH160заменяет верхний ключ его хешем HASH160.<pubKeyHash>кладёт хеш, зашитый в условии выхода.OP_EQUALVERIFYснимает два хеша и падает, если они не равны. Так проверяется, что предъявлен именно нужный ключ.OP_CHECKSIGснимает ключ и подпись, проверяет подпись и кладёт результат.
Прогони это руками ниже. Переключатели подсовывают чужой ключ (упадёт OP_EQUALVERIFY) или битую подпись (упадёт OP_CHECKSIG), и видно, на каком шаге ломается трата:
PUSH <sig>PUSH <pubkey>OP_DUPOP_HASH160PUSH <pubKeyHash>OP_EQUALVERIFYOP_CHECKSIGМини-интерпретатор такого языка это буквально match по опкоду поверх Vec как стека. Никакой магии, ровно та же диспетчеризация, что в нашем эмуляторе bcpu (урок 38 раздела Rust):
enum Op {
PushBytes(Vec<u8>),
Dup,
Hash160,
EqualVerify,
CheckSig,
}
fn eval(script: &[Op], sig_ctx: &SigContext) -> Result<bool, ScriptError> {
let mut stack: Vec<Vec<u8>> = Vec::new();
for op in script {
match op {
Op::PushBytes(bytes) => stack.push(bytes.clone()),
Op::Dup => {
let top = stack.last().ok_or(ScriptError::Underflow)?.clone();
stack.push(top);
}
Op::Hash160 => {
let top = stack.pop().ok_or(ScriptError::Underflow)?;
stack.push(hash160(&top).to_vec());
}
Op::EqualVerify => {
let a = stack.pop().ok_or(ScriptError::Underflow)?;
let b = stack.pop().ok_or(ScriptError::Underflow)?;
if a != b {
return Err(ScriptError::EqualVerifyFailed);
}
}
Op::CheckSig => {
let pubkey = stack.pop().ok_or(ScriptError::Underflow)?;
let sig = stack.pop().ok_or(ScriptError::Underflow)?;
stack.push(vec![u8::from(sig_ctx.verify(&pubkey, &sig))]);
}
}
}
// трата разрешена, если на вершине не ноль и не пусто
Ok(stack.last().is_some_and(|top| top.iter().any(|&byte| byte != 0)))
}
Одна честная оговорка про последнюю строку. Настоящая функция CastToBool в Bitcoin считает ложью не только все нулевые байты, но и «отрицательный ноль»: числа в Script знаковые, и байт 0x80 на месте старшего это минус ноль. Наш упрощённый any(|&byte| byte != 0) посчитал бы такое значение истиной, реальный интерпретатор нет.
P2SH: платим на хеш скрипта
P2PKH зашивает условие в выход целиком. Но что, если условие сложное (мультиподпись из пяти ключей), а отправитель не хочет ни знать его, ни платить за длинный адрес? Тут появляется P2SH: выход блокируется на хеш скрипта, а сам скрипт (redeem script) предъявляется только при трате.
scriptPubKey: OP_HASH160 <scriptHash> OP_EQUAL
scriptSig: <данные...> <redeemScript>
Проверка идёт в два прохода: сначала хеш предъявленного redeem script сверяется с зашитым, потом сам redeem script исполняется на стеке с оставленными данными. Отправитель заплатил на короткий адрес 3..., а всю сложность и всю комиссию за неё взял на себя получатель.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.