OP_CHECKCONTRACTVERIFY #32080

pull bigspider wants to merge 4 commits into bitcoin:master from Merkleize:ccv-core changing 17 files +1606 −43
  1. bigspider commented at 6:00 am on March 17, 2025: none

    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):

    • adding batch validation for Schnorr signatures seems to require a similar shared state, like the BatchSchnorrVerifier class using a mutex in this experimental PR. Cross-Input Signature Aggregation would also have the same nature.
    • OP_TXHASH used a transaction-wide TxHashCache, guarded by a mutex.
    • OP_VAULT also has some deferred checks based on information that accumulated during the inputs’ Script evaluation.

    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:

    • an output’s amount is insufficient as per the CCV checks across inputs;
    • an output is used as a target of two 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:

    • a deferred checks framework like in the OP_VAULT implementation. This is functionally equivalent, but I found the approach slightly harder to implement, and requiring more boilerplate code overall.
    • making the Script validation sequential for the inputs of the same transaction (that is, distributing transactions to different worker threads instead of individual inputs). This would make any present and future tx-wide semantic easier and avoid mutexes. However, this refactoring might require bigger changes to the interpreter, and should carefully be benchmarked as well.

    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

  2. DrahtBot commented at 6:00 am on March 17, 2025: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32080.

    Reviews

    See the guideline for information on the review process. A summary of reviews will appear here.

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #31989 (BIP-119 (OP_CHECKTEMPLATEVERIFY) (regtest only) by jamesob)
    • #31868 ([IBD] specialize block serialization by l0rinc)
    • #31519 (refactor: Use std::span over Span by maflcko)
    • #31460 (fuzz: Expand script verification flag testing to segwit v0 and tapscript by dergoegge)
    • #29491 ([EXPERIMENTAL] Schnorr batch verification for blocks by fjahr)
    • #29247 (CAT in Tapscript (BIP-347) by 0xBEEFCAF3)

    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.

  3. DrahtBot added the label CI failed on Mar 17, 2025
  4. DrahtBot commented at 6:13 am on March 17, 2025: contributor

    🚧 At least one of the CI tasks failed. Debug: https://github.com/bitcoin/bitcoin/runs/38866989648

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

  5. bigspider commented at 6:40 am on March 17, 2025: none
    I will look into the CI asap. UPDATE: fixed the obvious ones; script_tests fails as only functional tests have currently been updated, so that remains to be done.
  6. bigspider force-pushed on Mar 17, 2025
  7. bigspider force-pushed on Mar 17, 2025
  8. bigspider force-pushed on Mar 17, 2025
  9. consensus: Cross-input script validation framework
    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 as will beused for the amount logic of OP_CHECKCONTRACTVERIFY,
    that performs amount checks that are aggregate across multiple inputs.
    
    This could also be used for other applications, like batch validation
    and cross-input Schnorr signature aggregation.
    00b417d533
  10. consensus: Add OP_CHECKCONTRACTVERIFY e87a90bc41
  11. test: Add functional tests for OP_CHECKCONTRACTVERIFY 760902d69f
  12. test: Add functional tests for a vault construction based on CHECKCONTRACTVERIFY 0a1d149112
  13. bigspider force-pushed on Mar 17, 2025
  14. in src/script/interpreter.cpp:1884 in 0a1d149112
    1879+    }
    1880+
    1881+    CScript scriptPubKey = (flags == CCV_FLAG_CHECK_INPUT) ? txdata->m_spent_outputs[index].scriptPubKey : txTo->vout.at(index).scriptPubKey;
    1882+
    1883+    if (scriptPubKey.size() != 1 + 1 + 32 || scriptPubKey[0] != OP_1 || scriptPubKey[1] != 32) {
    1884+        return set_error(serror, SCRIPT_ERR_CHECKCONTRACTVERIFY_WRONG_ARGS);
    


    Sjors commented at 5:55 pm on March 18, 2025:

    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)


    bigspider commented at 8:29 am on March 20, 2025:

    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.

  15. DrahtBot added the label Needs rebase on Mar 20, 2025
  16. DrahtBot commented at 10:09 am on March 20, 2025: contributor

    🐙 This pull request conflicts with the target branch and needs rebase.

  17. in test/functional/feature_checkcontractverify_vaults.py:50 in 0a1d149112
    45+    A UTXO that can be spent either:
    46+    - with the "recover" clause, sending it to a PT2R output that has recover_pk as the taproot key
    47+    - with the "trigger" clause, sending the entire amount to an Unvaulting output, after providing a 'withdrawal_pk'
    48+    - with the "trigger_and_revault" clause, sending part of the amount to an output with the same script as this Vault, and the rest
    49+      to an Unvaulting output, after providing a 'withdrawal_pk'
    50+    - with the alternate_pk using the keypath spend (if provided; the key is NUMS_KEY otherwise)
    


    Sjors commented at 11:45 am on March 24, 2025:

    In 0a1d149112af2835166e3d6e62a925df2e416e4e “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?).


    Sjors commented at 1:10 pm on March 24, 2025:
     0diff --git a/test/functional/feature_checkcontractverify_vaults.py b/test/functional/feature_checkcontractverify_vaults.py
     1index 37e0bbd39f..60871850c1 100755
     2--- a/test/functional/feature_checkcontractverify_vaults.py
     3+++ b/test/functional/feature_checkcontractverify_vaults.py
     4@@ -47,19 +47,16 @@ class Vault(P2TR):
     5     - with the "trigger" clause, sending the entire amount to an Unvaulting output, after providing a 'withdrawal_pk'
     6     - with the "trigger_and_revault" clause, sending part of the amount to an output with the same script as this Vault, and the rest
     7       to an Unvaulting output, after providing a 'withdrawal_pk'
     8-    - with the alternate_pk using the keypath spend (if provided; the key is NUMS_KEY otherwise)
     9     """
    10
    11-    def __init__(self, alternate_pk: Optional[bytes], spend_delay: int, recover_pk: bytes, unvault_pk: bytes, *, has_partial_revault=True, has_early_recover=True):
    12-        assert (alternate_pk is None or len(alternate_pk) == 32) and len(
    13-            recover_pk) == 32 and len(unvault_pk) == 32
    14+    def __init__(self, spend_delay: int, recover_pk: bytes, unvault_pk: bytes, *, has_partial_revault=True, has_early_recover=True):
    15+        assert len(recover_pk) == 32 and len(unvault_pk) == 32
    16
    17-        self.alternate_pk = alternate_pk
    18         self.spend_delay = spend_delay
    19         self.recover_pk = recover_pk
    20         self.unvault_pk = unvault_pk
    21
    22-        unvaulting = Unvaulting(alternate_pk, spend_delay, recover_pk)
    23+        unvaulting = Unvaulting(spend_delay, recover_pk)
    24
    25         self.has_partial_revault = has_partial_revault
    26         self.has_early_recover = has_early_recover
    27@@ -68,7 +65,7 @@ class Vault(P2TR):
    28         trigger = ("trigger",
    29                    CScript([
    30                        # data and index already on the stack
    31-                       0 if alternate_pk is None else alternate_pk,  # pk
    32+                       recover_pk,  # pk
    33                        unvaulting.get_taptree(),  # taptree
    34                        0,  # standard flags
    35                        OP_CHECKCONTRACTVERIFY,
    36@@ -89,7 +86,7 @@ class Vault(P2TR):
    37                 OP_CHECKCONTRACTVERIFY,
    38
    39                 # data and index already on the stack
    40-                0 if alternate_pk is None else alternate_pk,  # pk
    41+                recover_pk,  # pk
    42                 unvaulting.get_taptree(),  # taptree
    43                 0,  # standard flags
    44                 OP_CHECKCONTRACTVERIFY,
    45@@ -113,7 +110,7 @@ class Vault(P2TR):
    46             ])
    47         )
    48
    49-        super().__init__(NUMS_KEY if alternate_pk is None else alternate_pk, [
    50+        super().__init__(recover_pk, [
    51             trigger, [trigger_and_revault, recover]])
    52
    53
    54@@ -123,18 +120,15 @@ class Unvaulting(AugmentedP2TR):
    55     - with the "recover" clause, sending it to a PT2R output that has recover_pk as the taproot key
    56     - with the "withdraw" clause, after a relative timelock of spend_delay blocks, sending the entire amount to a P2TR output that has
    57       the taproot key 'withdrawal_pk'
    58-    - with the alternate_pk using the keypath spend (if provided; the key is NUMS_KEY otherwise)
    59     """
    60
    61-    def __init__(self, alternate_pk: Optional[bytes], spend_delay: int, recover_pk: bytes):
    62-        assert (alternate_pk is None or len(alternate_pk)
    63-                == 32) and len(recover_pk) == 32
    64+    def __init__(self, spend_delay: int, recover_pk: bytes):
    65+        assert len(recover_pk) == 32
    66
    67-        self.alternate_pk = alternate_pk
    68         self.spend_delay = spend_delay
    69         self.recover_pk = recover_pk
    70
    71-        super().__init__(NUMS_KEY if alternate_pk is None else alternate_pk)
    72+        super().__init__(recover_pk)
    73
    74     def get_scripts(self) -> TapTree:
    75         # witness: <withdrawal_pk>
    76@@ -144,7 +138,7 @@ class Unvaulting(AugmentedP2TR):
    77                 OP_DUP,
    78
    79                 -1,
    80-                0 if self.alternate_pk is None else self.alternate_pk,
    81+                self.recover_pk,
    82                 -1,
    83                 CCV_FLAG_CHECK_INPUT,
    84                 OP_CHECKCONTRACTVERIFY,
    85@@ -183,13 +177,11 @@ class Unvaulting(AugmentedP2TR):
    86
    87 # We reuse these specs for all the tests
    88 vault_contract = Vault(
    89-    alternate_pk=None,
    90     spend_delay=10,
    91     recover_pk=recover_pubkey_xonly,
    92     unvault_pk=unvault_pubkey_xonly
    93 )
    94 unvault_contract = Unvaulting(
    95-    alternate_pk=None,
    96     spend_delay=10,
    97     recover_pk=recover_pubkey_xonly
    98 )
    

    Sjors commented at 3:24 pm on March 24, 2025:

    Here’s a branch with the above patch https://github.com/bitcoin/bitcoin/commit/f87381ae825b7c0263f0b30d2f93e2687c8fca6f, but also renaming:

    • unvault_{privkey, pubkey_xonly} to hot_{privkey,pk,pubkey_xonly}
    • recover_{privkey, pubkey_xonly} to cold_{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: https://github.com/bitcoin/bitcoin/compare/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.


    Sjors commented at 4:40 pm on March 24, 2025:

    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:

    1. Using the cold keys directly, using the key path of the trigger output
    2. 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.


    Sjors commented at 5:45 pm on March 24, 2025:

    Mmm, I think I’m still missing something. Pushed a commit 43e2d9792bae4894563625f8ae86c3e504d44004 that robs the vault by simply changing the destination address and witness at withdrawal time.


    Fixed in 4379b2545ff79592b0967193e830db4397f975f6, by more or less restoring the code I deleted in e937281acd9a0c5fb1573e13a90672114f55e4cb.


    bigspider commented at 10:19 pm on March 24, 2025:

    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 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.

    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 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, 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’).


    Sjors commented at 9:17 am on March 25, 2025:

    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?


    Sjors commented at 10:32 am on March 25, 2025:

    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).


    bigspider commented at 10:49 pm on March 25, 2025:

    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 control alternate_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.).


    Sjors commented at 8:07 am on March 26, 2025:
    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).

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2025-03-28 15:12 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me