August 17

Темпография. Практический эксперимент с nLockTime, BIP68 и OP_CHECKSEQUENCEVERIFY

Темпография

Введение

Когда говорят о безопасности Bitcoin, разговор почти всегда сводится к приватному ключу // seed-фразе, аппаратным кошелькам, мультисигам, резервным копиям и физической защите (ключей).

Но у Bitcoin есть ещё один уровень защиты, который принципиально отличается от защиты самого ключа:

Можно ограничить не то, что способно подписать транзакцию, но и момент, когда подписанная транзакция или конкретный UTXO вообще может быть потрачен.

Иными словами, даже наличие правильной подписи ещё не обязательно означает возможность немедленно переместить BTC. Для этого в Bitcoin существуют несколько механизмов времени. В этой статье практически решил продемонстрировать два:

  1. Абсолютную временную блокировку через nLockTime;
  2. Относительную блокировку 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"

Bitcoin Core запускался nfr:

bitcoind -datadir="$HOME/BitcoinSignet" -daemon

Проверка сети в свою очередь - следующей командой:

bitcoin-cli -datadir="$HOME/BitcoinSignet" getblockchaininfo

После синхронизации получил буквально следующее:

  • "chain": "signet"
  • "initialblockdownload": false

Использовал отдельный кошелёк под всё это дело: locktime-lab (назвать можете иначе). Важно другое: эксперимент полностью был изолирован от майннет сети. Во-первых, чтобы для вас это не было дорого (см. выше); во-вторых, чтобы можно было быстрее "пощупать" результаты труда.

Вопрос №03. А в Биткоин точно есть целых два разных понятия времени?

Короткий ответ: да. Поэтому перед экспериментами необходимо разделить две конструкции.

nLockTime

Это абсолютное ограничение. Логика приблизительно такая: не раньше блока X или при соответствующем значении (переводчик с языка "блоков" на язык стандартного времени - задача тривиальная): не раньше времени T.

Т.е. транзакция существует целиком, но до наступления соответствующего условия считается в статусе "non-final", т.е. по-русски говоря: не финализированной (хотя в сети Биткоин такого понятия нет, в моих экспериментах финализация появляется - в виду доп. слоя защиты).

BIP68 + CSV

Это уже относительное время. Логика тут другая: этот конкретный UTXO, ко. можно потратить только после того, как он достаточно "состарится". Сразу давайте пример приведу - берём упрощённое представление UTXO (входов/выходов) и получаем такую примитивную схему:

  • 1 подтверждение
  • 2
  • 3
  • после необходимого возраста - разрешаем трату.

Ещё раз! Важно! Это фундаментально другое свойство: 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"

На момент создания было так:

  • Current height: 317395
  • nLockTime: 317396

Именно 317396 становится абсолютным ограничением транзакции.

Вопрос №05. А как создать транзакцию с nLockTime?

Для этого как раз создаём сырую транзакцию (raw transaction):

RAW=$(bitcoin-cli -datadir="$HOME/BitcoinSignet" \
createrawtransaction \
'[{"txid":"d40dee16911e2eac06355f69b3eaddb2cd6f2905f00398c4d23e896a444f69e2","vout":0,"sequence":4294967294}]' \
"{\"$DEST\":0.00000700}" \
"$LOCK")

Здесь принципиально важны два поля:

  • locktime = 317396
  • sequence = 4294967294

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

То есть транзакция уже была:

  • создана;
  • подписана;
  • имеет фиксированный TXID;
  • содержит правильный вход;
  • содержит правильный выход.

Но blockchain ещё находился на высоте 317395.

Вопрос №07. Как проверить мемпул? И получить отказ :)

Используем для этого простой и очень удобный RPC:

bitcoin-cli -datadir="$HOME/BitcoinSignet" \
testmempoolaccept "[\"$SIGNED\"]"

Результат будет примерно следующий:

  • "allowed": false
  • "reject-reason": "non-final"
  • "reject-details": "non-final"

Это первая ключевая точка эксперимента:

  • Подпись правильная.
  • UTXO существует.
  • Транзакция корректно сформирована.
  • Но Bitcoin Core отказывается принимать её в mempool: non-final

Почему? Причина и есть заданное временное ограничение, т.е. всё работает верно, поэтому в данном случае отказ - это плюс, а не минус.

Вопрос №08. Что случается, когда проходит время?

Итак, в моём эксперименте высота блокчейна увеличилась (доросла / возвысилась - как хотите) с блока 317395 до 317411.

Саму транзакцию не изменял. Но повторил:

bitcoin-cli -datadir="$HOME/BitcoinSignet" \
testmempoolaccept "[\"$SIGNED\"]"

Теперь уже получилось так: "allowed": true.

Bitcoin Core также показал:

  • vsize: 110
  • fees: n/a
  • base: 0.00000300

Таким образом, одна и та же транзакция прошла два состояния (запишу символично): саму подпись менять не потребовалось! Ещё раз: последняя фраза - ключ к разгадке всех этих виляний хвостом с 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

А главное:

  • issolvable: true
  • hasprivatekeys: false

Дескриптор (в моём случае) означает по существу: валидную подпись и относительную задержку одновременно.

На уровне скриптов 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"

TXID у меня такой получился:

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) имела такие выходные параметры:

  • version: 2
  • locktime: 0
  • sequence: 3

После 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.

Трата имела такие параметры:

  • version: 2
  • locktime: 0
  • sequence: 6

И всё это на высоте: 317493.

Bitcoin Core ответил просто: allowed: true. Поэтому для окончательного эксперимента отложенный период был увеличен мной ещё раз.

Вопрос №20. Что там с финальным боссом экспериментом? Older(20)

А ничего. Создал условие:

DESC20="wsh(and_v(v:pk(0297308e84ffc38a99db28bdb8f4e38bacc05620119b1f03b16a8e397881b1e7ff),older(20)))"

Проверил:

bitcoin-cli -datadir="$HOME/BitcoinSignet" \
getdescriptorinfo "$DESC20"

Bitcoin Core вернул:

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}")

RAW - PSBT:

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\"]"

Получил именно тот результат, который хотели поймать:

  • "allowed": false
  • "reject-reason": "non-BIP68-final"
  • "reject-details": "non-BIP68-final"

Это центральный результат второго эксперимента. Почему? Потому что транзакция уже существовала. Она была подписана. Скрипт был известен. Ключ был правильным. Но 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

Затем наступил момент ключевой:

  • tip=317516 | allowed=True | ACCEPT
  • >>> CSV20 MATURED AT TIP 317516

Вот это и был главный результат внутри моей лаборатории.

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}

Результат: 0

Попытался дальше так:

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, а как один из строительных блоков более серьёзной архитектуры. Я бы её по итогу обозначил так:

  1. Сид защищает секрет.
  2. Мультисиг распределяет доверие.
  3. Таймлок добавляет время как отдельный слой защиты.
  4. Сочетание же этих механизмов позволяет проектировать систему, в кот. компрометация одного элемента ещё не обязательно означает мгновенную и необратимую потерю BTC.

Вот такие дела и

До!