-
Notifications
You must be signed in to change notification settings - Fork 37.7k
OP_CHECKCONTRACTVERIFY #32080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
OP_CHECKCONTRACTVERIFY #32080
Conversation
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code Coverage & BenchmarksFor details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32080. ReviewsSee the guideline for information on the review process. ConflictsReviewers, this pull request conflicts with the following ones:
If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first. |
🚧 At least one of the CI tasks failed. HintsTry to run the tests locally, according to the documentation. However, a CI failure may still
Leave a comment here, if you need help tracking down a confusing failure. |
I will look into the CI asap. |
d7cc490
to
0a1d149
Compare
CScript scriptPubKey = (flags == CCV_FLAG_CHECK_INPUT) ? txdata->m_spent_outputs[index].scriptPubKey : txTo->vout.at(index).scriptPubKey; | ||
|
||
if (scriptPubKey.size() != 1 + 1 + 32 || scriptPubKey[0] != OP_1 || scriptPubKey[1] != 32) { | ||
return set_error(serror, SCRIPT_ERR_CHECKCONTRACTVERIFY_WRONG_ARGS); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How much resources can a (mempool) transaction waste by violating this rule in the last input / output after making the interpreter do a bunch of hashing work?
And is that worse than existing taproot script allows? And would it be better with a restriction on the number of combinations, e.g. if inputs can't refer to other inputs? Or should these bounds checks be done earlier for the whole transaction? (that seems impossible because the index comes from the stack, which you can't predict through static analysis of the script and witness)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In terms of hashing, it is no different than opcodes like OP_SHA256
, as it hashes a stack element (prefixed with an x-only key, so up to 552 bytes in total).
So restricting the opcode to just the current input shouldn't make any difference.
That should be significantly smaller than the the cost of the double tweak, which should instead be comparable (and priced appropriately in sigops) to signatures – hopefully a bit less than that, but this needs a proper benchmark.
Note that no non-witness parts of the transaction is involved in the hashing (except the current input's internal key/taptree when the corresponding parameter is -1), so caching issues like the ones one might have for opcodes like OP_CHECKSIG
or OP_CHECKTEMPLATEVERIFY/TXHASH
shouldn't be a concern.
- with the "trigger" clause, sending the entire amount to an Unvaulting output, after providing a 'withdrawal_pk' | ||
- with the "trigger_and_revault" clause, sending part of the amount to an output with the same script as this Vault, and the rest | ||
to an Unvaulting output, after providing a 'withdrawal_pk' | ||
- with the alternate_pk using the keypath spend (if provided; the key is NUMS_KEY otherwise) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In 0a1d149 "test: Add functional tests for a vault construction based on CHECKCONTRACTVERIFY"
Can alternate_pk
and recover_pk
just be the same? That way there's only two key sets needed for a vault: one hot, one cold. And you're avoiding the use of a NUMS point.
BIP 345 (OP_VAULT
) says:
the recovery key can include a number of spending conditions, e.g. a time-delayed fallback to an "easier" recovery method, in case the highly secure key winds up being too highly secure.
In your scheme you can have those recovery options in the main taptree. That allows for recovery without an initial trigger transaction / unvaulting, i.e. even if you lost the hot key (I think BIP 345 allows that too?).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
patch
diff --git a/test/functional/feature_checkcontractverify_vaults.py b/test/functional/feature_checkcontractverify_vaults.py
index 37e0bbd39f..60871850c1 100755
--- a/test/functional/feature_checkcontractverify_vaults.py
+++ b/test/functional/feature_checkcontractverify_vaults.py
@@ -47,19 +47,16 @@ class Vault(P2TR):
- with the "trigger" clause, sending the entire amount to an Unvaulting output, after providing a 'withdrawal_pk'
- with the "trigger_and_revault" clause, sending part of the amount to an output with the same script as this Vault, and the rest
to an Unvaulting output, after providing a 'withdrawal_pk'
- - with the alternate_pk using the keypath spend (if provided; the key is NUMS_KEY otherwise)
"""
- def __init__(self, alternate_pk: Optional[bytes], spend_delay: int, recover_pk: bytes, unvault_pk: bytes, *, has_partial_revault=True, has_early_recover=True):
- assert (alternate_pk is None or len(alternate_pk) == 32) and len(
- recover_pk) == 32 and len(unvault_pk) == 32
+ def __init__(self, spend_delay: int, recover_pk: bytes, unvault_pk: bytes, *, has_partial_revault=True, has_early_recover=True):
+ assert len(recover_pk) == 32 and len(unvault_pk) == 32
- self.alternate_pk = alternate_pk
self.spend_delay = spend_delay
self.recover_pk = recover_pk
self.unvault_pk = unvault_pk
- unvaulting = Unvaulting(alternate_pk, spend_delay, recover_pk)
+ unvaulting = Unvaulting(spend_delay, recover_pk)
self.has_partial_revault = has_partial_revault
self.has_early_recover = has_early_recover
@@ -68,7 +65,7 @@ class Vault(P2TR):
trigger = ("trigger",
CScript([
# data and index already on the stack
- 0 if alternate_pk is None else alternate_pk, # pk
+ recover_pk, # pk
unvaulting.get_taptree(), # taptree
0, # standard flags
OP_CHECKCONTRACTVERIFY,
@@ -89,7 +86,7 @@ class Vault(P2TR):
OP_CHECKCONTRACTVERIFY,
# data and index already on the stack
- 0 if alternate_pk is None else alternate_pk, # pk
+ recover_pk, # pk
unvaulting.get_taptree(), # taptree
0, # standard flags
OP_CHECKCONTRACTVERIFY,
@@ -113,7 +110,7 @@ class Vault(P2TR):
])
)
- super().__init__(NUMS_KEY if alternate_pk is None else alternate_pk, [
+ super().__init__(recover_pk, [
trigger, [trigger_and_revault, recover]])
@@ -123,18 +120,15 @@ class Unvaulting(AugmentedP2TR):
- with the "recover" clause, sending it to a PT2R output that has recover_pk as the taproot key
- with the "withdraw" clause, after a relative timelock of spend_delay blocks, sending the entire amount to a P2TR output that has
the taproot key 'withdrawal_pk'
- - with the alternate_pk using the keypath spend (if provided; the key is NUMS_KEY otherwise)
"""
- def __init__(self, alternate_pk: Optional[bytes], spend_delay: int, recover_pk: bytes):
- assert (alternate_pk is None or len(alternate_pk)
- == 32) and len(recover_pk) == 32
+ def __init__(self, spend_delay: int, recover_pk: bytes):
+ assert len(recover_pk) == 32
- self.alternate_pk = alternate_pk
self.spend_delay = spend_delay
self.recover_pk = recover_pk
- super().__init__(NUMS_KEY if alternate_pk is None else alternate_pk)
+ super().__init__(recover_pk)
def get_scripts(self) -> TapTree:
# witness: <withdrawal_pk>
@@ -144,7 +138,7 @@ class Unvaulting(AugmentedP2TR):
OP_DUP,
-1,
- 0 if self.alternate_pk is None else self.alternate_pk,
+ self.recover_pk,
-1,
CCV_FLAG_CHECK_INPUT,
OP_CHECKCONTRACTVERIFY,
@@ -183,13 +177,11 @@ class Unvaulting(AugmentedP2TR):
# We reuse these specs for all the tests
vault_contract = Vault(
- alternate_pk=None,
spend_delay=10,
recover_pk=recover_pubkey_xonly,
unvault_pk=unvault_pubkey_xonly
)
unvault_contract = Unvaulting(
- alternate_pk=None,
spend_delay=10,
recover_pk=recover_pubkey_xonly
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a branch with the above patch f87381a, but also renaming:
unvault_{privkey, pubkey_xonly}
tohot_{privkey,pk,pubkey_xonly}
recover_{privkey, pubkey_xonly}
tocold_{privkey,pk,pubkey_xonly}
With that terminology I find it easier to follow: using their hot key the user unvaults into some arbitrary withdrawal address, which can be recovered to their cold key (with no signature, just knowledge of the recover leaf).
I missed a spot where the alternate key was used:
master...Sjors:bitcoin:2025/03/op_cvv_vault_test
So now the withdraw leaf is shorter.
Though maybe I'm misunderstanding what the alternate_pk was good for. Maybe to provide an example of having two OP_CHECKCONTRACTVERIFY
codes in a single leaf? The trigger_and_revault
leaf already does that.
It could still make sense to have an example of a more advanced vault, but it should be seperate. The simplest possible vault is a good way to learn how this opcode works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to be smart and also use -1
instead of cold_pk
in recover_leaf
, but that doesn't work. Not sure why.
IIUC there's two recovery methods. If so it would be good to demonstrate both:
- Using the cold keys directly, using the key path of the trigger output
- Using the recovery condition
(2) can be done without using the cold keys, which is nice if you're not sure yet how your hot key was compromised.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for checking out the demo!
For the naming, I tried to use names similar to BIP-345, except that I name the "Vault" and "Unvaulting" state of the FSM which seems more natural in CCV-lingo (where you define the multi-utxo contract as a finite-state-machine).
Can
alternate_pk
andrecover_pk
just be the same? That way there's only two key sets needed for a vault: one hot, one cold. And you're avoiding the use of a NUMS point.
You could, but I don't think that makes a lot of sense in practice, as the alternate_pk
has no covenant restriction. I think in simple vaults it doesn't make sense to use the alternate_pk
; you could only make sense to use it for deep cold storage (e.g. a musig of cold keys, that is: "you can override the 2-step withdrawal process, but you have to go through the hassle of using multiple cold keys").
Note that using the alternate pubkey makes the default spending paths more expensive, since NUMS has the special encoding 0
in CCV - so one might still prefer to use a NUMS in the keypath and put a pk(alternate_pk)
in a tapleaf.
In general, I don't think the alternate_pk
is very interesting for a vault, but it comes natural to define it because of how taproot works.
The keypath is certainly more interesting in multi-party contracts, where the parties can cooperatively agree to sign the transaction with a musig taproot keypath, instead of putting the covenant on-chain.
In your scheme you can have those recovery options in the main taptree. That allows for recovery without an initial trigger transaction / unvaulting, i.e. even if you lost the hot key (I think BIP 345 allows that too?).
Yes, in the demo I made a simple recovery mechanism that sends to a predetermined address, but you can of course get arbitrarily more creative if you wish!
I tried to be smart and also use
-1
instead ofcold_pk
inrecover_leaf
, but that doesn't work. Not sure why.
Putting -1
means in the pk
argument means that you reuse the taproot internal key for your output, which you probably only want to do in a recursive contract (but then like you probably also want to put -1
for the taptree, like the first CCV
in the trigger_and_revault
leaf, that recycles both the internal key and the taptree in order to 'send back to yourself').
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to use names similar to BIP-345
The terms {vault,trigger,withdrawal,recovery} transaction
from that BIP are indeed useful. But I found it a bit easier to follow when calling the relevant keys "hot" and "cold" and not having a third one.
You could, but I don't think that makes a lot of sense in practice, as the alternate_pk has no covenant restriction.
Maybe I'm still confused then. In my branch I renamed recover_pk
to cold_pk
. So the cold key can be used for keypath spending, ignoring the covenant. And it's where the coins are sent to in a recovery scenario.
In general, I don't think the alternate_pk is very interesting for a vault, but it comes natural to define it because of how taproot works.
So then it's better to leave it out of the example for improved readability?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to be smart and also use -1 instead of cold_pk in recover_leaf, but that doesn't work. Not sure why.
Putting -1 means in the pk argument means that you reuse the taproot internal key for your output
I'm still not sure if I understand this. I assumed that the taproot internal key is unchanged by the trigger transaction, i.e. it's still the cold key (in my branch).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'trigger' transaction moves from the Vault state to the Unvaulting state.
- a Vault UTXO has no embedded data, so the internal key equals what I called the naked key (which is the
alternate_pk
in this case). - an Unvaulting UTXO has some data embedded (the withdrawal_pk), so the internal key is something like
tweak(alternate_pk, SHA256(alternate_pk || data))
. So if you put-1
to send to an output with the "same public key as the input's internal key", you'd have the tweaked key as the output. Which is still spendable if you controlalternate_pk
, but probably not what you wanted.
If you really wanted to "send to an output with the same naked key as the input you're spending, you could pass naked key, data and taptree, then do a CCV check on the input (with CCV_FLAG_CHECK_INPUT
) to check it matches what's on the stack, then do a CCV on the output (now that you have the naked key on the stack). But I think you don't want to do this.
In most cases, working with directed acyclic graphs (meaning, the construction always moves forward, and introspection on the input is only used to get the input's data on the stack), so far, seems to be enough to do anything interesting.
Recursive contracts are the exception, where you send to exactly the same (naked_key, taptree)
pair as the input, either with no data (like in vaults), or after updating the data with some new data (this will be useful in shared UTXOs, sidechains, etc.).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, that makes sense. There's no concept of a global naked key that is preserved between steps of the program (except through the workaround you mention).
Renamed the Still trying to locally reproduce the unit test failures. |
Only active on regtest. This is only for PR hygiene: CCV is not proposed as a soft-fork on its own, but having minimal deployment code in the PR helps to keep things clean.
Adds a framework for validation checks that persist an individual input's script validation, allowing to persist state across the evaluations of multiple inputs of a transaction. This will be used for the amount logic of OP_CHECKCONTRACTVERIFY, that performs amount checks that are aggregate across multiple inputs. It could also be used for other applications, like batch validation and cross-input Schnorr signature aggregation.
I added a workaround to disable the two failing tests from the |
🐙 This pull request conflicts with the target branch and needs rebase. |
@@ -2698,7 +2712,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, | |||
std::vector<CScriptCheck> vChecks; | |||
bool fCacheResults = fJustCheck; /* Don't cache results if we're actually connecting blocks (still consult the cache, though) */ | |||
TxValidationState tx_state; | |||
if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, parallel_script_checks ? &vChecks : nullptr)) { | |||
if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, tx_exec_store, parallel_script_checks ? &vChecks : nullptr)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bigspider In commit 2c1deef, naively it looks like this diff would resolve the rebase for you.
bool tx_ok;
TxValidationState tx_state;
// If CheckInputScripts is called with a pointer to a checks vector, the resulting checks are appended to it. In that case
// they need to be added to control which runs them asynchronously. Otherwise, CheckInputScripts runs the checks before returning.
- if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, tx_exec_store, parallel_script_checks ? &vChecks : nullptr)) {
if (control) {
std::vector<CScriptCheck> vChecks;
- tx_ok = CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, &vChecks);
+ tx_ok = CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, tx_exec_store, &vChecks);
if (tx_ok) control->Add(std::move(vChecks));
} else {
- tx_ok = CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache);
+ tx_ok = CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, tx_exec_store);
}
if (!tx_ok) {
This is a first draft implementation of the
OP_CHECKCONTRACTVERIFY
(CCV
) opcode.CCV
enables to build Script-based state machines that span across multiple transactions, by providing an ergonomic tool to commit to - and introspect - the Script and possibly some data that is committed inside inputs or outputs.Related to this PR:
CCV
is a general purpose opcode to build state machines based on UTXO; therefore it is not strictly tied to any specific application.However, as vaults are probably the simplest application of such state machines, the PR also includes a functional test for a very simple (but practical) vault construction, only using
CCV
. More feature-complete vaults are achievable in combinations with other opcodes.This PR does not contain activation logic, and can't be merged as-is, since it would make the opcode immediately active.
I'd like encourage limiting discussions to the implementation, while specs can be discussed in the BIP draft PR and external places like Delving Bitcoin.
Challenges with the cross-input amount logic
A challenge in implementing
CCV
is the cross-input amount semantic: the opcode adds restrictions on the output amounts that are inherently transaction-wide.This difficulty seems to be common to other possible changes in the working of the Script interpreter (including without new soft forks):
BatchSchnorrVerifier
class using a mutex in this experimental PR. Cross-Input Signature Aggregation would also have the same nature.TxHashCache
, guarded by a mutex.In this implementation, I added a general purpose
TransactionExecutionData
struct to contain persisting data that is accessible during Script evaluation. This allows the interpreter to cause a failure if either:CCV
checks across inputs;CCV
checks with incompatible amount semantic.By the monotonic nature of these checks, it is impossible that a transaction validity is affected by the order of evaluation of the input Scripts. This is an important property for any usage of the struct in the Script interpreter.
As the operations done while the mutex is locked in
CCV
are trivial, it should have a negligible impact on validation. This should, however, be validated with the appropriate benchmarks.Alternative approaches to the implementation could be:
Finally, there is a possibility that changing the semantic of
CCV
somehow might make it possible to implement semantically (or practically) equivalent checks, without requiring explicit sinchronization during input execution. Here's a gist from darosior with some early ideas about using the taproot annex for this purpose. At this time, I'm not convinced that this direction is possible without significant tradeoffs - but I have no proof of this, and look forward to your ideas.Related links
OP_CHECKCONTRACTVERIFY
(with or without other opcodes).