Темпография. Практический эксперимент с nLockTime, BIP68 и OP_CHECKSEQUENCEVERIFY
Введение
Когда говорят о безопасности Bitcoin, разговор почти всегда сводится к приватному ключу // seed-фразе, аппаратным кошелькам, мультисигам, резервным копиям и физической защите (ключей).
Но у Bitcoin есть ещё один уровень защиты, который принципиально отличается от защиты самого ключа:
Можно ограничить не то, что способно подписать транзакцию, но и момент, когда подписанная транзакция или конкретный UTXO вообще может быть потрачен.
Иными словами, даже наличие правильной подписи ещё не обязательно означает возможность немедленно переместить BTC. Для этого в Bitcoin существуют несколько механизмов времени. В этой статье практически решил продемонстрировать два:
- Абсолютную временную блокировку через nLockTime;
- Относительную блокировку UTXO через BIP68 + OP_CHECKSEQUENCEVERIFY (CSV).
Главная цель эксперимента - не просто прочитать документацию Bitcoin Core, а увидеть поведение сети непосредственно в связке: валидная подпись + правильный ключ + правильный UTXO ≠ обязательная возможность потратить BTC прямо сейчас (в момент транзакции).
Все эксперименты проводились локально через Bitcoin Core v31.1.0 в публичной сети Signet, т.к. использовать реальные BTC для этого - перебор (как по мне).
Вопрос №01. Что именно хотел проверить?
Исходная идея была связана с безопасностью хранения Bitcoin. Предположим, злоумышленник каким-либо образом получил приватный ключ. В обычной схеме: если приватный ключ скомпроментирован - создаётся транзакция - через подпись и монеты транслируются через них в сеть, т.е. BTC меняют "статус" на : "украдены".
Но можно ли построить UTXO так, чтобы самого приватного ключа было недостаточно?
Например: приватный ключ + условие времени = возможность расходования? Короткий ответ: да, можно. Но зачем? Всё просто: компрометация ключа в момент Tо не обязательно означает возможность немедленной кражи.
Это особенно интересно для архитектур:
- холодного хранения;
- наследства;
- восстановления;
- резервных путей восстановления;
- отложенного вывода;
- vault-подобных конструкций;
- схем, в кот. время используется как доп. граница безопасности.
Чтобы понять фундамент, отдельно верифицировал абсолютное и относительное время.
Вопрос №02. Что нужно для опытов?
Короткий ответ: лаборатория. Для эксперимента мной был создан отдельный Bitcoin Core datadir (пишу для Mac, т.к. для Linux повторить не сложно, а с Win не советую работать никому):
mkdir -p "$HOME/BitcoinSignet" nano "$HOME/BitcoinSignet/bitcoin.conf"
bitcoind -datadir="$HOME/BitcoinSignet" -daemon
Проверка сети в свою очередь - следующей командой:
bitcoin-cli -datadir="$HOME/BitcoinSignet" getblockchaininfo
После синхронизации получил буквально следующее:
Использовал отдельный кошелёк под всё это дело: locktime-lab (назвать можете иначе). Важно другое: эксперимент полностью был изолирован от майннет сети. Во-первых, чтобы для вас это не было дорого (см. выше); во-вторых, чтобы можно было быстрее "пощупать" результаты труда.
Вопрос №03. А в Биткоин точно есть целых два разных понятия времени?
Короткий ответ: да. Поэтому перед экспериментами необходимо разделить две конструкции.
nLockTime
Это абсолютное ограничение. Логика приблизительно такая: не раньше блока X или при соответствующем значении (переводчик с языка "блоков" на язык стандартного времени - задача тривиальная): не раньше времени T.
Т.е. транзакция существует целиком, но до наступления соответствующего условия считается в статусе "non-final", т.е. по-русски говоря: не финализированной (хотя в сети Биткоин такого понятия нет, в моих экспериментах финализация появляется - в виду доп. слоя защиты).
BIP68 + CSV
Это уже относительное время. Логика тут другая: этот конкретный UTXO, ко. можно потратить только после того, как он достаточно "состарится". Сразу давайте пример приведу - берём упрощённое представление UTXO (входов/выходов) и получаем такую примитивную схему:
Ещё раз! Важно! Это фундаментально другое свойство: nLockTime смотрит на абсолютную координату в блокчейне, CSV может привязать расходование к возрасту конкретного UTXO.
Вопрос №04. Как смастерить абсолютный таймлок через nLockTime?
Короткий ответ: просто. Но давайте на пальцах и подробно.
4.1. Постановка задачи
Вот я получили Signet UTXO: TXID:
d40dee16911e2eac06355f69b3eaddb2cd6f2905f00398c4d23e896a444f69e2
А далее см. на vout (сокращение от vector output) - выход транзакции или порядковый номер (индекс) конкретного выхода внутри транзакции): он будет равен нулю (0).
Затем получаем значение передаваемое (value): 1000 sats, скажем. И после этого создаём новый адрес назначения:
DEST=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ -rpcwallet="locktime-lab" \ getnewaddress "locktime-destination" bech32) echo "$DEST"
В моём случае получилось вот такое значение:
tb1q9gau0rtpet6dpncgk749zxnu4yn03vkx58nruy
После этого надо определить текущую высоту:
H=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" getblockcount) LOCK=$((H + 1)) echo "Current height: $H" echo "nLockTime: $LOCK"
Именно 317396 становится абсолютным ограничением транзакции.
Вопрос №05. А как создать транзакцию с nLockTime?
Для этого как раз создаём сырую транзакцию (raw transaction):
RAW=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \
createrawtransaction \
'[{"txid":"d40dee16911e2eac06355f69b3eaddb2cd6f2905f00398c4d23e896a444f69e2","vout":0,"sequence":4294967294}]' \
"{\"$DEST\":0.00000700}" \
"$LOCK")Здесь принципиально важны два поля:
Sequence специально был не установлен в финальное 0xffffffff, чтобы nLockTime имел нужный эффект ("переключатель" перевода).
Вопрос №06. Что с подписанием
Тут тоже всё не сложно: чтобы подписать транзакцию в кошельке, надо выполнить следующую команду:
SIGNED=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ -rpcwallet="locktime-lab" \ signrawtransactionwithwallet "$RAW" | python3 -c 'import sys,json; print(json.load(sys.stdin)["hex"])')
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ decoderawtransaction "$SIGNED"
Bitcoin Core показал мне следующее:
- txid: ce4bd00889e65d42372c4fdda5b63373d2b2f30ff54285043a862fdbcf2baee0
- version: 2
- locktime: 317396
- sequence: 4294967294
Но blockchain ещё находился на высоте 317395.
Вопрос №07. Как проверить мемпул? И получить отказ :)
Используем для этого простой и очень удобный RPC:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ testmempoolaccept "[\"$SIGNED\"]"
Результат будет примерно следующий:
Это первая ключевая точка эксперимента:
- Подпись правильная.
- UTXO существует.
- Транзакция корректно сформирована.
- Но Bitcoin Core отказывается принимать её в mempool: non-final
Почему? Причина и есть заданное временное ограничение, т.е. всё работает верно, поэтому в данном случае отказ - это плюс, а не минус.
Вопрос №08. Что случается, когда проходит время?
Итак, в моём эксперименте высота блокчейна увеличилась (доросла / возвысилась - как хотите) с блока 317395 до 317411.
Саму транзакцию не изменял. Но повторил:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ testmempoolaccept "[\"$SIGNED\"]"
Теперь уже получилось так: "allowed": true.
Таким образом, одна и та же транзакция прошла два состояния (запишу символично): саму подпись менять не потребовалось! Ещё раз: последняя фраза - ключ к разгадке всех этих виляний хвостом с Bitcoin.Core.
Вопрос №09. Что с трансляцией?
Для начала надо было отправить уже существующий HEX:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ sendrawtransaction "$SIGNED"
Bitcoin Core вернул следующее:
ce4bd00889e65d42372c4fdda5b63373d2b2f30ff54285043a862fdbcf2baee0
Транзакция попала в мемпул. Проверку сделал так:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ getmempoolentry \ ce4bd00889e65d42372c4fdda5b63373d2b2f30ff54285043a862fdbcf2baee0
И она (проверка) - подтвердила её (транзакции) присутствие (в мемпуле).
Вопрос №10. Что доказал первый эксперимент?
Получил следующую схему: TX создана - (потом) TX подписана - (затем) подпись валидна - (но) TX всё равно не принимается - (потому что) nLockTime условие не достигнуто (буквально - not yet satisfied).
После же изменения состояния блокчейна: Та же самая (!) TX - та же самая подпись - те же самые входные данные - те же самые выходные данные - и в итоге тот же самый TXID, но дают другой статус (опять же буквально: "allowed=true").
То есть временное ограничение действительно является независимым условием допустимости транзакции.
Вопрос №11. В чём суть тогда второго эксперимента?
Второй эксперимент значительно интереснее с точки зрения архитектуры хранения. (По крайне мере - для меня лично). Теперь хотел проверить буквально следующее: можно ли создать UTXO, кот. нельзя потратить, пока с момента его подтверждения не пройдёт заданное количество блоков?
Вопрос №12. Как создать ключ для CSV?
Для этого использовал адрес: tb1qz0ug9q9rnpvfauly3l2g2er6s56jk4f9jynr85.
Его дескриптор (если коротко, то дескриптор вывода в Биткоине (output descriptor) - определённая строка-шаблон: она описывает, как кошелёк создает адреса, вычисляет скрипты для транзакций (scripts) и восстанавливает ключи; важно, что дескриптор объединяет правила генерации адресов и сами публичные/приватные ключи в едином стандарте):
wpkh([4fd9c5b6/84h/1h/0h/0/4]0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff)#z3gcvhx2
0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff
Вопрос №13. Каков первый CSV дескриптор?
Для изучения механизма использовал небольшую задержку:
PUBKEY="0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff" DESC="wsh(and_v(v:pk($PUBKEY),older(3)))" bitcoin-cli -datadir="$HOME/BitcoinSignet" \ getdescriptorinfo "$DESC"
Bitcoin Core вернул такой ответ:
wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(3)))#sfk0un53
Дескриптор (в моём случае) означает по существу: валидную подпись и относительную задержку одновременно.
На уровне скриптов Bitcoin Core позже декодировал это следующим образом <PUBKEY> OP_CHECKSIGVERIFY 3 OP_CHECKSEQUENCEVERIFY.
Вопрос №14. Как получить адрес?
DESC_FULL="wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(3)))#sfk0un53" bitcoin-cli -datadir="$HOME/BitcoinSignet" \ deriveaddresses "$DESC_FULL"
tb1qnpzwyqf8syagtw4u53h4d0t4kslt73sm7d5pq0uvl7w090wv540qup2f0l
Это уже не обычный P2WPKH (Pay-to-Witness-Public-Key-Hash) - стандартный формат адреса и тип транзакции в сети Биткоин, появившийся после внедрения обновления SegWit (Segregated Witness)). Bitcoin Core определяет его как: witness_v0_scripthash, то есть деньги теперь отправлялись на состояние сценария (буквально: script condition).
Вопрос №15. Что дальше с CSV UTXO?
Создал я спонсирующую транзакцию (funding transaction):
CSV_FUND_RAW=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \
createrawtransaction \
'[{"txid":"b2e4c95b57d400196dc31b057b9d59b5eabffe8597fb2afe0aa8d0b9d4a2f3b4","vout":2}]' \
'{"tb1qnpzwyqf8syagtw4u53h4d0t4kslt73sm7d5pq0uvl7w090wv540qup2f0l":0.00008000}')CSV_FUND_SIGNED=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \
-rpcwallet="locktime-lab" \
signrawtransactionwithwallet "$CSV_FUND_RAW" |
python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["hex"]) if d["complete"] else print("INCOMPLETE")')bitcoin-cli -datadir="$HOME/BitcoinSignet" \ testmempoolaccept "[\"$CSV_FUND_SIGNED\"]"
И получил буквально следующее: allowed: true.
Что до трансляции то здесь работает следующее:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ sendrawtransaction "$CSV_FUND_SIGNED"
f5ccccba414a8ffb6e88f331f2ccd68135dcdb83a82cd9df1aaf9a5bf639a575
Позже транзакцию нашёл непосредственно в блокчейне (тут уже немного помучал регулярные выражения через AI:
TXID="f5ccccba414a8ffb6e88f331f2ccd68135dcdb83a82cd9df1aaf9a5bf639a575" for H in 317478 317479 317480; do BH=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" getblockhash $H) echo "Checking block $H..." bitcoin-cli -datadir="$HOME/BitcoinSignet" getblock "$BH" 1 | grep -q "$TXID" && echo ">>> FOUND IN BLOCK $H" done
Результат простой оказался: >>> FOUND IN BLOCK 317478
16. Почему понадобился мне PSBT?
Здесь обнаружилась важная практическая особенность. Обычный кошелёк знает приватный ключ, но состояние расходов находится внутри WSH-скрипта. Поэтому для корректного подписания Bitcoin Core необходимо сообщить:
- какой UTXO расходуется;
- какой witness_script его контролирует;
- какой pubkey используется;
- какое relative-lock условие должно выполняться.
Поэтому и перешёл к PSBT. Схема стала после этого примерно такая: RAW (сырые данные) - PSBT - UTXO + информация дескриптора (буквально именно: descriptor information) - подпись в кошельке (wallet signature) - и так самая финализация (да, буквально именно: finalize) и в итоге - подписание сырой транзакции (raw signed transaction: оставляю англ. термины, т.к. при пошаговом повторении вам с ними всё равно придётся столкнуться).
Вопрос №17. Что там со spend с sequence=3?
Созданная транзакция (и даже потраченная: spending transaction) имела такие выходные параметры:
После utxoupdatepsbt (по сути это RPC-команда (или, если хотите, метод программного интерфейса) в клиентах ноды Биткоина - в том числе и Bitcoin Core, с кот. работал, кот. обновляет частично подписанную транзакцию (PSBT), добавляя в неё недостающие данные об используемых UTXO (неизрасходованных выходах) из локального набора UTXO или мемпула) Bitcoin Core уже видел, что:
- witness_utxo: 0.00008000 BTC (и при этом)
- witness_script: <PUBKEY> OP_CHECKSIGVERIFY 3 OP_CHECKSEQUENCEVERIFY
Подписание же происходило так:
PSBT3=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ -rpcwallet="locktime-lab" \ walletprocesspsbt "$PSBT2" true ALL true | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["psbt"])')
Финализация в свою очередь - так:
FINAL=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ finalizepsbt "$PSBT3")
Получил буквально следующее: complete: true. А ещё testmempoolaccept (если оч. коротко, то это тоже RPC-команда в Bitcoin Core, которая проверяет, будет ли сырая транзакция принята в мемпул: команда не отправляет транзакцию в сеть и не сохраняет её, а лишь тестирует на соответствие правилам консенсуса и текущей политике узла): allowed: true.
Это доказало работоспособность всей цепочки: descriptor - WSH - CSP - SBT - подпись (signature) - финализация (finalized TX) - политика мемпула (mempool policy/consensus checks).
Но older(3) оказался слишком коротким для красивой демонстрации перехода во времени: пока вручную выполнял команды, UTXO уже успевал достаточно состариться. Поэтому эксперимент последовательно увеличил сначала до older(6), а затем до older(20).
Вопрос №18. Как провести контрольный отрицательный эксперимент?
Отдельно попробовал создать spend для older(3) с параметром sequence = 2. Т.е. вместо необходимых: sequence = 3 (и) после:
finalizepsbt "$PSBT_BAD3"
Bitcoin Core вернул мне: "complete": false. То есть вместо готового HEX остался PSBT. Это тоже важный (как по мне) результат. Условие скрипта было оч. простым: older(3), а транзакция (spending transaction) пыталась "предъявить": sequence=2, поэтому условие и не выполнялось. Следовательно, корректная приватная подпись сама по себе не позволила финализировать трату.
Вопрос №19. Как осуществить переход к older(6)?
Короткий ответ: никак :). Но это, конечно же - шутка: вы могли просто-напросто устать, дочитав до этого места, поэтому развеял ваш сон и теперь - давайте дальше.
DESC6="wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(6)))"
Bitcoin Core отвечает в этом случае в своём стиле - что-то вроде:
wsh(and_v(v:pk(...),older(6)))#hl82wqkj
tb1qf4d7pc3fhgxv6h45zpkxcjmzw9qgsdh3kgqc7ufrtnwv6w7hxagqwwkvnd
Транзакция (Funding transaction):
- TXID: 3f75090c74baebba1fc5a5ac8323c5d54300cb22c3dc7f0d1b8202006ed58968
- Она была мной найдена блоке 317488.
Bitcoin Core ответил просто: allowed: true. Поэтому для окончательного эксперимента отложенный период был увеличен мной ещё раз.
Вопрос №20. Что там с финальным боссом экспериментом? Older(20)
DESC20="wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(20)))"
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ getdescriptorinfo "$DESC20"
wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(20)))#8hsakux6
DESC20_FULL='wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(20)))#8hsakux6' CSV20_ADDR=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ deriveaddresses "$DESC20_FULL" | python3 -c 'import sys,json; print(json.load(sys.stdin)[0])')
tb1qnmkl7psvvst782y4wwmldvgkas7wzw7ax2fjua525wqvukstz6hqpxl6k6
21. И что тогда с funding older(20)?
А тоже - ничего плохого! Средства перенёс из предыдущего older(6) UTXO:
CSV20_FUND_RAW=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \
createrawtransaction \
'[{"txid":"3f75090c74baebba1fc5a5ac8323c5d54300cb22c3dc7f0d1b8202006ed58968","vout":0,"sequence":6}]' \
"{\"$CSV20_ADDR\":0.00006000}")CSV20_FUND_PSBT=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ converttopsbt "$CSV20_FUND_RAW")
Добавил дескриптор предыдущего older(6):
CSV20_FUND_PSBT2=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ utxoupdatepsbt \ "$CSV20_FUND_PSBT" \ '["wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(6)))#hl82wqkj"]')
CSV20_FUND_PSBT3=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ -rpcwallet="locktime-lab" \ walletprocesspsbt "$CSV20_FUND_PSBT2" true ALL true | python3 -c 'import sys,json; print(json.load(sys.stdin)["psbt"])')
CSV20_FUND_FINAL=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ finalizepsbt "$CSV20_FUND_PSBT3")
Получил: complete: true. И TXID funding transaction:
ea0eaea966b2cb8e80328d5333acb6fb282a51b8d0977a1288ff11f1a78eacbe
Тогда как testmempoolaccept: allowed: true.
Вопрос №22. Как можно зафииксировать рождение UTXO?
Вопрос на самом деле не тревиальный. Но перед трансляцией сделала было так:
Height before broadcast: 317494
Funding TX была отправлена и затем найдена в:
>>> CSV20 FUNDING FOUND IN BLOCK 317495
Это и была ключевая точка эксперимента:
CSV20 UTXO born at block 317495
И релевантный блок (relative lock)приобрёл, если хотите, наблюдаемый смысл! Или верифицирован стал - как хотите.
Вопрос №23. Как создать трату и больше никогда не изменять её?
Сначала просто поймём, что транзакцию создал такую (spending transaction, опять же, если точнее): TXID:
7841b47fb5e86b24d8a2bb57213d117e2f6a4177c92819439f3354cd7dbf2458
Проверка структуры при этом вышла такой:
- version: 2
- locktime: 0
- input: ea0eaea966b2cb8e80328d5333acb6fb282a51b8d0977a1288ff11f1a78eacbe:0
- sequence: 20
Полученный HEX сохранил следующим образом:
printf '%s\n' "$CSV20_SPEND_HEX" > \ "$HOME/BitcoinSignet/csv20-spend.hex"
Это принципиально важная часть методологии. Поэтому - подчеркну: ВАЖНАЯ ЧАСТЬ!
- не пересоздавал;
- не переподписывал;
- не меняли выходы;
- не меняли входы;
- не меняли последовательность (данных);
- не меняли комиссию.
Таким образом, дальнейший эксперимент проверял одни и те же байты транзакции при разных состояниях блокчейна: а это и есть проверка на время.
Вопрос №24. Что там с первой проверкой дальше? Older(20)
Блокчейн когда был на высоте: 317495, а траты параметр показывал: sequence = 20, проверил:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ testmempoolaccept "[\"$CSV20_SPEND_HEX\"]"
Получил именно тот результат, который хотели поймать:
Это центральный результат второго эксперимента. Почему? Потому что транзакция уже существовала. Она была подписана. Скрипт был известен. Ключ был правильным. Но Bitcoin Core всё равно говорил: НЕТ (NO). Почему? Потому что относительное временное условие ещё не выполнялось.
Вопрос №25. Где оно - автоматически наблюдаем старение UTXO?
Чтобы не пересобирать транзакцию, использовал один и тот же сохранённый HEX. Цикл был примерно такой:
LAST=""
while true; do
H=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" getblockcount)
if [ "$H" != "$LAST" ]; then
HEX=$(cat "$HOME/BitcoinSignet/csv20-spend.hex")
RESULT=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \
testmempoolaccept "[\"$HEX\"]")
ALLOWED=$(echo "$RESULT" |
python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["allowed"])')
REASON=$(echo "$RESULT" |
python3 -c 'import sys,json; d=json.load(sys.stdin)[0]; print(d.get("reject-reason","ACCEPT"))')
echo "tip=$H | allowed=$ALLOWED | $REASON"
if [ "$ALLOWED" = "True" ]; then
echo ">>> CSV20 MATURED AT TIP $H"
break
fi
LAST="$H"
fi
sleep 5
doneТеперь эксперимент шёл без моего прямого участия.
Вопрос №26. Каков реальный результат?
- tip=317495 | allowed=False | non-BIP68-final
- tip=317496 | allowed=False | non-BIP68-final
- tip=317497 | allowed=False | non-BIP68-final
- tip=317498 | allowed=False | non-BIP68-final
- tip=317499 | allowed=False | non-BIP68-final
- tip=317500 | allowed=False | non-BIP68-final
Транзакция всё это время оставалась одной (той же самой):
7841b47fb5e86b24d8a2bb57213d117e2f6a4177c92819439f3354cd7dbf2458
Затем наступил момент ключевой:
Вот это и был главный результат внутри моей лаборатории.
27. Что фактически произошло?
На уровне ключа не произошло ничего. Не получил новый приватный ключ. Не создал новую подпись. Не изменил даже скрипт. Но и не создал новую транзакцию (да, снова речь и именно про spending transaction). Зато изменилось состояние блокчейна (и только оно). Схематично это выглядит так:
- Некая транзакция (SAME SIGNED TRANSACTION)
- Выоста: 317495
- Статус: non-BIP68-final
- Высота: 317496
- Статус: non-BIP68-final
- Высота: 317497
- Статус: non-BIP68-final
- Высота: 317516
- Статус: ACCEPT.
Следовательно: валидность подписи и возможность расходования UTXO - не одно и то же, а это я и доказывал в рамках эксперимента. Bitcoin Script способен наложить дополнительное условие времени и тем самым можем придумать свой защищённый вольт.
28. Почему именно 317516 и его не стоит просто считать как 317495 + 20?
Это место важно описывать аккуратно, потому что здесь, уверен, многие могут допустить ошибки. Почему? Потому что наивная модель могла бы выглядеть так: 317495 + 20 = 317515. Да? Да. Но есть "но": отсюда можно было бы ожидать принятия ровно там, на этой высоте этого блока.
Но мой-то эксперимент зафиксировал первое наблюдение (буквально: first observed ACCEPT) на 317516 именно!
И на мой взгляд это хороший пример того, почему в Биткоине нельзя заменять реальные правила проверки бытовым подсчётом "прошло N блоков".
Собственно, если вы разберёте BIP68, то узнаете, что он использует "relative lock-time semantics", а Bitcoin Core при добавлении в мемпул проверяет допустимость транзакции относительно "chain state" и следующего потенциального блока. Поэтому для практической реализации нужно опираться на точные политики (если будете искать, то контекст такой: consensus/policy semantics), а не на самостоятельно придуманное арифметическое правило.
Для статьи и эксперимента при этом важнее даже другое: граница была обнаружена мной экспериментально - одной и той же транзакцией! До неё: non-BIP68-final, а вот после: ACCEPT.
Вопрос №29. В nLockTime против CSV: что я увидел?
Если коротко и не повторяясь: что они очень разные. И это тоже была гипотеза, кот. надо было проверить.
Вопрос №30. Самый важный вывод для безопасности: он какой?
Первоначальный вопрос был не академическим: по крайне мере - для меня. Напомню ,что звучал он, вопрос, примерно так: "Можно ли использовать время как ещё один слой защиты Bitcoin, помимо хранения приватного ключа?".
Экспериментальный ответ у меня получился короткий: "Да". Непосредственно получил ситуацию, кот. описывается краткой схемой: "Даже если у злоумышленника есть приватный ключ и он может создать корректную подпись, но условие расходования при этом ещё не выполнено, то средства нельзя переместить по такому пути" (нечто подобное получите и вы, если будете соблюдать подобные условия).
Это очень интересная примитивная конструкция. Но здесь есть важнейшая оговорка: простое добавление таймлока само по себе не превращает обычный кошелёк в безопасный вольт или смарт-аккаунт.
Если существует другой, немедленный, путь расходования тем же ключом, злоумышленник воспользуется им. Поэтому реальная защита строится не как. А как? Как система альтернативных путей трат.
Вопрос №31. Где и когда это становится действительно интересным?
Опять же, отвечу коротко: когда вы совмещаете мультисиг с описанной выше схемой. Поскольку и первый, и второй аспект уже описал, то вам остаётся лишь соединить их вместе :).
Вопрос №32. Что время даёт защитнику?
Главная ценность отложенных транзакций - окно реакции. Или спасения. Опять же - как хотите. В традиционной схеме что имеем? Ключ украден - транзакция - конец игры. В правильно спроектированной (delayed/vault) архитектуре потенциально появляется иной подход: компрометация (ключа/сида) - запасной путь - окно возможностей для исправления - детект взлома - восстановление.
То есть безопасность меняется с позиции "не дать украсть ключ" на позицию более сложную, но и более защищённую: "даже если определённый ключ или путь скомпрометирован, у системы могут существовать дополнительные ограничения и время на реакцию". Понятно, что дураков не спасут никакие дороги, но для, как говорится sapienti sat.
Это уже гораздо более сильная модель менеджмента (опять же - для меня).
Вопрос №33. Неужели CSV - не есть отмена транзакции?
Очень важно не сделать из эксперимента неправильный вывод. CSV сам по себе не создаёт кнопку Cancel. НЕ создаёт! Зато он говорит: этот путь (spending path) невалиден до выполнения временного условия.
Т.е. если злоумышленник и владелец после всего обладают одинаковыми возможностями расходования, начинается обычная гонка: кто быстрей - того и тапки биткоины/сатоши.
Поэтому полноценная архитектура требует дополнительных элементов:
- альтернативных ключей;
- доп. путей;
- мультисига;
- заранее продуманной структуры UTXO;
- мониторинга;
- менеджмента комиссий (приоритета их);
- иногда - заранее подготовленных транзакций;
- строгой модели того, какой ключ способен использовать какую ветку и т.д.
Вопрос №34. Что ещё показал эксперимент с точки зрения безопасности?
Интересным оказался не только сам CSV. Я столкнулися с рядом реальных особенностей Bitcoin Core.
Например, "watch-only descriptor" нельзя было просто импортировать в кошелёк с включёнными приватными ключами. Ошибка формата: "Cannot import descriptor without private keys to a wallet with private keys enabled", - буквально об этом.
Поэтому мной был создан отдельный watch-only wallet:
bitcoin-cli -datadir="$HOME/BitcoinSignet" \ createwallet "csv-watch" true true
Затем возникло ещё одно ограничение: нода работала в моде, кот. буквально называется "prune mode", т.е. режим обрезки. Попытка полного рескана дала ещё одну ошибку: "Rescan failed... This error could be caused by pruning...".
Это полезное практическое наблюдение, считаю: для серьёзной инфраструктуры необходимо заранее решить, нужна ли вам именно урезанная или полная нода, потому что возможности восстановления истории (кошелька/дескриптора) различаются. Местами - существенно.
Вопрос №35. Что-то ещё?
Да, ещё одна ошибка, которую полезно сохранить. В процессе работы переменная shell с подписанной транзакцией оказалась пустой:
echo ${#SIGNED}sendrawtransaction "$SIGNED"
Но такая попытка дала лишь: TX decode failed.
Восстановил HEX непосредственно из кошелька поэтому:
SIGNED=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \ -rpcwallet="locktime-lab" \ gettransaction ce4bd00889e65d42372c4fdda5b63373d2b2f30ff54285043a862fdbcf2baee0 | python3 -c 'import sys,json; print(json.load(sys.stdin)["hex"])')
echo ${#SIGNED}Показала: 382, а трансляция прошла нормально. Отсюда простой вывод: shell-переменная - так себе хранилище критически важной Bitcoin-транзакции. Точнее - НЕ хранилище вообще.
Для реального воркфлоу подписанный HEX/PSBT должен сохраняться как самостоятельный артефакт и проверяться перед использованием.
Да, вот так просто! Именно поэтому в финальном CSV-тесте уже сделал:
printf '%s\n' "$CSV20_SPEND_HEX" > \ "$HOME/BitcoinSignet/csv20-spend.hex"
Вопрос №36. Что в итоге доказал-то? :)
Без теоретических предположений получил 4 результата. Давайте коротко по ним ещё раз пройдусь.
Первый. Абсолютный timelock работает
То есть: valid signed TX + future nLockTime = non-final. После наступления соответствующего состояния: same signed TX = allowed
Второй. Relative timelock работает
То есть: valid signed TX + immature CSV condition = non-BIP68-final. После старения UTXO: same signed TX = allowed
Третий. Наличие приватного ключа не эквивалентно безусловной возможности расходования
То есть: PRIVATE KEY ≠ UNCONDITIONAL CONTROL, если сам UTXO создан с дополнительными условиями (их и называю: spending conditions).
Четвёртый. Время действительно может выступать самостоятельным секьюрити-примитивом
Вопрос №37. А каков главный практический вывод?
Обычная модель не кастодиального хранения выглядит так: BTС-ключ - компрометация. Но Bitcoin Script позволяет построить гораздо более интересную модель: BTC UTXO - политика трат - время - валидный путь (для трат) и добавить более сложные конструкции (мультисиги, ключи восстановления, таймлоки и проч. - это всё выше уже описывал, повторяться не буду).
И для меня важно, чтпостепенно ухожу от примитива: "Ко знает seed - тот владеет BTC", - к куда более свежей идее: "BTC контролирует тот, кто способен удовлетворить условия расходования конкретного UTXO".
И ключ - лишь одно из возможных условий!
Вопрос №38. Может хватит и подведём черту? :)
Давайте. Тем более что эксперимент начался с довольно простого вопроса: "Можно ли за счёт времени сделать хранение BTC безопаснее?". На тестах с Signet последовательно увидел: 2 кейса и простой ответ: "Да, можно".
При этом в обоих случаях изменение произошло не в приватном ключе и не в подписи!
Я понимаю, что оформил эту мысль уже 3 тезисами, но, поверьте, она крайне важная и того стоит. Почему? Потому что изменилось лишь время: точнее - состояние блокчейна относительно заданного временного условия.
Именно поэтому таймлоки интересны не как экзотическая функция Bitcoin Script, а как один из строительных блоков более серьёзной архитектуры. Я бы её по итогу обозначил так: