Add state commitment functions for persistent storage via Taproot - #46
Add state commitment functions for persistent storage via Taproot#46schoen wants to merge 5 commits into
Conversation
| fn own_script_hash_with_state(state_data: u256) -> u256 { | ||
| // This is the bulk of our "compute state commitment" logic. | ||
| let tap_leaf: u256 = jet::tapleaf_hash(); | ||
| let state_ctx1: Ctx8 = jet::tapdata_init(); | ||
| let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, state_data); | ||
| let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx2); | ||
| let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); | ||
|
|
||
| // Compute a taptweak using this. | ||
| let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0; | ||
| let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node); | ||
|
|
||
| // Turn the taptweak into a script hash. | ||
| let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); | ||
| let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); // Segwit v1, length 32 | ||
| let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key); | ||
| jet::sha_256_ctx_8_finalize(hash_ctx3) | ||
| } |
There was a problem hiding this comment.
I think we need to break this function down into two types of functions:
- Functions that calculate the leaf for different types
- A function that takes an
internal_keyand atap_nodeand calculates the resultingscript_hash
The thing is, the Taproot Storage structure can vary across different contracts, so std needs to provide universal functions
| pub fn load(state_data: u256) { | ||
| // Assert that the input state is correct, i.e. "load". | ||
| // | ||
| // Enforce that the state commitment hash in the Taptree alongside | ||
| // the current input is equal to state_data. (This must be a result | ||
| // of the transaction builder's having constructed the prior | ||
| // transaction so that this is true.) | ||
| // Panics otherwise. | ||
| assert!(jet::eq_256( | ||
| own_script_hash_with_state(state_data), | ||
| unwrap(jet::input_script_hash(jet::current_index())) | ||
| )); | ||
| } | ||
|
|
||
| pub fn store(new_state: u256, index: u32) { | ||
| // Assert that the output state is correct, i.e. "store". | ||
| // | ||
| // The index parameter specifies the output index where the | ||
| // new copy of this covenant is located. Depending on the | ||
| // covenant convention, that could be jet::current_index() | ||
| // (same index as input), some other hard-coded index | ||
| // demanded by convention, or could even be flexible and | ||
| // determined by a witness parameter. | ||
| // | ||
| // Enforce that the state commitment hash in the Taptree alongside | ||
| // the specified output is equal to new_state. (This must be a | ||
| // result of the transaction builder constructing the transaction | ||
| // so that this is true.) | ||
| // Panics otherwise. | ||
| assert!(jet::eq_256( | ||
| own_script_hash_with_state(new_state), | ||
| unwrap(jet::output_script_hash(index)) | ||
| )); | ||
| } |
There was a problem hiding this comment.
These functions are also very useful, but we can only provide a universal interface for them if the user passes the pre-calculated tap_node and internal_key.
There was a problem hiding this comment.
I certainly see the problem that these functions are not as general as they could be and I think the instinct to generalize them more is a very good one. I was talking to an AI last week about this, and it pointed out the related issue that, once we have a way to recognize a specific other contract, we may want to have covenants that have an "exit through other covenant" (e.g. you can withdraw from the existing covenant into a specified new covenant with a derived state). In a sense @apoelstra is currently actively working on the "way to recognize a specific other contract" for these purposes, and the mechanisms for multiple covenants to deliberately cooperate on cospending and recognizing each other.
I also think it's a good catch that the internal key hard-coding is questionable (as another example of the generality issue).
I'm a little torn about this: I really regret not having a straightforward and officially documented way for people to do covenant enforcement right now. I can see that this way is possibly making too many assumptions and is not general enough, but I would love to figure out how to get covenant-enforcement mechanisms into the standard library early on so that people can make use of them, since covenants are such an important use case for Simplicity!
The greatest level of generality for these functions would presumably also not assume that the destination contract has the same identity as the current contract (but allow that to be specified). The possible disadvantages of this generality are (1) @apoelstra hasn't finished specifying some of the tools that we might need for that, and (2) understanding how to use it might be more intimidating for developers, as instead of an interface like
store(state: u256, index: u32)
we might get something more like
store(tap_node: u256, version: u32, internal_key: u256, covenant_id: u256, some_other_thing: u256, index: u32, state: u256)
This is great in a way. because it has more flexibility and more use cases, but also potentially far more challenging for people to understand. Perhaps we should implement that more general mechanism, and then also wrap it with a basic_covenant_store() and basic_covenant_load() or something that fill in the normal defaults for "a recursive covenant with the default NUMS key on this specific blockchain", etc.?
There was a problem hiding this comment.
I agree that there are a lot of things to consider in this matter, which will need to be resolved in the future or are already being resolved.
Therefore, std will continue to change many more times, depending on the language's capabilities and the needs of developers
Add
storage.simfwith state commitment functionsloadandstore.This is based on @apoelstra's stage management MVP functions, originally written around December 2025, with minor name changes and comment edits by me. Versions of these functions have been used in many covenants over the last several months. I'd like them to be in the standard library so that everyone can use them easily without copying and pasting them as "folklore" about how to do basic Simplicity state management.
There are 13 Simplex test cases created by Claude. I asked Claude for each individual test case, and I'm satisfied that they test the most important cases and that they work properly. We test that the storage does work when it should, and that loading or storing the wrong state (or storing to a Taproot or non-Taproot output other than a copy of the current program) fails. The other good thing is that we also test interoperability between state commitments generated by
rust-elementscalls and state commitments generated by thestorefunction's jets calls (which, as Claude pointed out to me, are two different implementations of portions of the relevant logic). This confirms that we can assert commitments from outside of a contract (like withhal-simplicityor LWK or a wallet) and verify them from inside of the contract. We already know this because we already do this in some actual contracts, but it's also directly tested here.As in #45, Claude wrote comments about what the various tests and helper functions do, which could be unnecessarily verbose, and which I'm happy to trim down if that would be preferable.
There may be a further discussion about whether we can usefully extend state commitments to let cooperating covenants load or store one another's state values, but this should wait for the covenant convention stuff (to include documented recommendations for how to accomplish this).