Implement 64 bit arithmetic op codes in the Script interpreter (consensus)

https://github.com/bitcoin/bitcoin/pull/29221

Host: christewart  -  PR author: Christewart

Many future extensions of the bitcoin protocol - such as OP_TLUV - want to create smart contracts based on the amount of satoshis in a bitcoin output.

Unfortunately, Satoshi values can be up to 51 bits in value, but we can only do math on 32 bit values in Script.

This means we cannot safely do math on Satoshi values in the interpreter without 64bit arithmetic!

This PR introduces 64bit arithmetic op codes and a new (to the interpreter) number encoding.

How arithmetic works currently in Script

Bitcoin has an embedded programming language called Script. Script has op codes such as OP_ADD and OP_SUB that allow you to pop 2 elements off of the stack, perform the arithmetic operation and push the resulting value back onto the stack. For instance, if my Script is using the old op codes

Example of how arithmetic currently works

OP_1 OP_2 OP_ADD OP_3 OP_EQUAL
[Stack: ] --(OP_1)--> [Stack: 1] --(OP_2)--> [Stack: 1, 2] --(OP_ADD)--> [Stack: 3] --(OP_3)--> [Stack: 3, 3] --(OP_EQUAL)--> [Stack: true]

Explanation:

  1. The initial state of the stack is empty.
  2. OP_1 pushes the number 1 onto the stack.
  3. OP_2 pushes the number 2 onto the stack.
  4. OP_ADD pops the top two elements (2 and 1), adds them, and pushes the result (3) onto the stack.
  5. OP_3 pushes the number 3 onto the stack.
  6. OP_EQUAL pops the top two elements (3 and 3), compares them, and if they are equal, verification succeeds. Otherwise, verification fails.

In this case, since the values are equal, the verification succeeds, and the final state of the stack is empty.

Simple enough, now lets create a Script with some larger number values that would not be possible without this PR.

In this example, we are going to assume we are doing math on 1,000 BTC. In satoshis, this number is 100,000,000,000. Encoded as CScriptNum the hex representation for 1,000 BTC is 0x00e8764817

0x00e8764817 0x00e8764817 OP_ADD 0x00d0ed902e OP_EQUAL
[Stack: ] --(0x00e8764817)--> [Stack: 0x00e8764817] --(0x00e8764817)--> [Stack: 0x00e8764817, 0x00e8764817] --(OP_ADD)--> [Stack: OP_ADD ERROR]

Explanation:

  1. The initial state of the stack is empty.
  2. 0x00e8764817 pushes the hexadecimal value 0x00e8764817 onto the stack.
  3. 0x00e8764817 pushes another instance of the same value onto the stack.
  4. OP_ADD consumes the two top stack elements and FAILS with an overflow exception

This version fails because OP_ADD can only consume 4 byte inputs. Even worse, this does not give the Script programmer the ability to handle the exception thrown by CScriptNum.

How arithmetic works with #29221

Three key differences exist in how 64-bit opcodes function compared to their previous counterparts:

  1. Enhanced Precision: They support 64 bits of precision, enabling more accurate arithmetic operations.

  2. Error Handling Capability: These opcodes provide error handling by pushing either true or false onto the stack, depending on whether the operation succeeds or fails.

  3. Standardized Encoding: They utilize a consistent fixed-length 8-byte number encoding format, aligning with conventions elsewhere in the Bitcoin codebase, such as in CTxOut::nValue.

As an illustration of the third difference, consider the encoding of 1,000 BTC. It would now be represented in the same format as seen on a block explorer (0x00e8764817000000) rather than 0x00e8764817 which is the CScriptNum encoding.

Example: Adding 1,000 BTC together with OP_ADD64

Here’s the same example from above with OP_ADD64 rather than OP_ADD with our new little endian encoding format rather than CScriptNum:

0x000e876481700000 0x000e876481700000 OP_ADD64 OP_DROP 0x001d0ed902e00000 OP_EQUAL
[Stack: ] --(0x00e8764817000000)--> [Stack: 0x00e8764817000000]
          --(0x00e8764817000000)--> [Stack: 0x00e8764817000000, 0x00e8764817000000]
          --(OP_ADD64)--> [Stack: 0x01d0ed902e000000, true]
          --(OP_DROP)--> [Stack: 0x01d0ed902e000000]
          --(0x01d0ed902e000000)--> [Stack: 0x01d0ed902e000000, 0x01d0ed902e000000]
          --(OP_EQUAL)--> [Stack: true]

Explanation:

  1. The initial state of the stack is empty.
  2. 0x00e8764817000000 pushes the hexadecimal value 0x00e8764817000000 onto the stack (representing 100,000,000,000 satoshis).
  3. Another instance of 0x00e8764817000000 is pushed onto the stack.
  4. OP_ADD64 attempts to pop the top two elements (0x00e8764817000000 and 0x00e8764817000000) to add them. The correct result of the addition 0x01d0ed902e000000 (representing 200,000,000,000 satoshis) is pushed onto the stack first, followed by true, indicating that the arithmetic executed correctly.
  5. OP_DROP drops the true pushed onto the stack by OP_ADD64 indicating the arithmetic operation was successfull.
  6. 0x001d0ed902e00000 pushes the hexadecimal value 0x001d0ed902e00000 onto the stack (representing 200,000,000,000 satoshis).
  7. OP_EQUAL compares the two top stack values 0x001d0ed902e00000 and pushes true onto the stack

Design questions

Signed vs unsigned arithmetic

Much of the implementation uses code from the elements blockchain. In elements they implemented new arithmetic opcodes as fixed size 64 bit signed integers. Do we have a use case for using signed math rather than unsigned math? The satoshi example would work with unsigned math (outputs can’t have negative value) even though sats are encoded as int64_t in the bitcoin protocol. Signed integer overflow is undefined behavior in the cpp spec

Existing opcode interop

What is the best way to interop with existing op codes such as OP_WITHIN, OP_SIZE, OP_CHECKSIGADD, etc? They may be explicitly or implicitly converted:

Explicit conversion op codes

Elements and, as a by product, this PR implement explicit casting op codes. They are OP_SCRIPTNUMTOLE64, OP_LE64TOSCRIPTNUM, OP_LE32TOLE64.

This means a Script programmer must explicitly cast stack tops in an opcode. For instance, from our example above

0x000e876481700000 0x000e876481700000 OP_ADD64 OP_DROP OP_LE64TOSCRIPTNUM OP_SIZE OP_8 OP_EQUALVERIFY OP_SCRIPTNUMTOLE64 0x001d0ed902e00000 OP_EQUAL

Implicit conversion opcodes

You could redefine opcodes such as OP_WITHIN, OP_SIZE, OP_CHECKSIGADD to be context dependent on the SigVersion. Lets look at a potential implementation for OP_SIZE

case OP_SIZE:
{
    // (in -- in size)
    if (stack.size() < 1)
        return set_error(serror, SCRIPT_ERR_INVALID_STACK_OPERATION);

    if (sigversion == SigVersion::BASE || sigversion == SigVersion::WITNESS_V0 || sigversion == SigVersion::TAPROOT || sigversion == SigVersion::TAPSCRIPT) {
	//this is for backwards compatability, we always want to use the old numbering
	//system for already deployed versions of the bitcoin protocol
        CScriptNum bn(stacktop(-1).size());
        stack.push_back(bn.getvch());
    } else {
        // All future soft forks assume 64-bit math.
        // Don't push variable length encodings onto
        // the stack when we are using SigVersion::TAPSCRIPT_64BIT.
        int64_t result = stacktop(-1).size();
        push8_le(stack, result);
    }
}

The key here is the else clause which assumes that every SigVersion that is NOT specified in the if clause uses 64bit signed integer fixed length numbers. This removes the need for conversion/casting op codes and makes the developer experience much nicer, IMO.

Encoding debate

There is a debate ongoing along 2 dimensions

  1. Whether fixed size encodings will encumber us for features introduced in future soft forks (such as 256bit scalar arithmetic)
  2. Whether moving away from CScriptNum will be too disruptive to the ecosystem and force everyone to update their tooling.

I’m not going to go into further detail about this debate as its been written about at length on delving bitcoin

Questions

  1. Did you review the PR? Concept ACK, approach ACK, tested ACK, or NACK? What was your review approach?

  2. What does the CScriptNum nMaxNumSize parameter do?

  3. Why was the fRequireMinimal flag introduced to CScriptNum?

  4. Is #29221 malleability safe? Why?

  5. What 2 opcodes accept 5 byte numeric inputs?

  6. The Script in the Explicit conversion op codes section will not work. Can you guess why? Hint: it has something to do with OP_LE64TOSCRIPTNUM.

  7. Is the OP_SIZE implementation safe for future soft forks? Hint: look at the control flow.

  8. What should we do with the old opcodes (OP_ADD, OP_SUB)?

Meeting Log

  117:00 <Chris_Stewart_5> #startmeeting
  217:00 <Guest93> hi
  317:00 <stickies-v> hi
  417:00 <Chris_Stewart_5> Hi everyone, this week we are discussing the proposed soft fork for 64bit arithmetic in the Script interpreter! :-)
  517:00 <Chris_Stewart_5> https://bitcoincore.reviews/29221
  617:01 <Chris_Stewart_5> the most INNOVATIVE soft fork ever proposed for bitcoin :P
  717:02 <stickies-v> moar maths
  817:02 <Chris_Stewart_5> 64bits of it!
  917:02 <glozow> hi
 1017:02 <dergoegge> hi
 1117:03 <emzy> hi
 1217:03 <Chris_Stewart_5> I put some examples of how Script currently works on the bitcoincore.reviews webpage. I used chatGPT to generate some (hopefully) readable ASCII art to show how Script and the stack work together
 1317:03 <glozow> they are very helpful examples!
 1417:04 <Chris_Stewart_5> Does anyone have a question about Script execution works before we start with 64bit specific questions?
 1517:04 <Guest93> what is 'the interpreter'?
 1617:05 <stickies-v> do you know why historically we limited operations to 4 bytes?
 1717:05 <Chris_Stewart_5> Guest93: The interpreter takes in instructions (such as 'OP_ADD') and data (such as a encoded numbers) and manipulates the stack based on the given instruction.
 1817:06 <Chris_Stewart_5> Guest93: another way to say it is, 'the interpreter' implements the Script programming language.
 1917:06 <glozow> I interpret interpreter to mean `EvalScript` in interpreter.cpp https://github.com/bitcoin/bitcoin/blob/b50554babdddf452acaa51bac757736766c70e81/src/script/interpreter.cpp#L406
 2017:06 <stickies-v> that's a lot of interpreting
 2117:07 <Chris_Stewart_5> stickies-v: I actually don't know why 32 bits specifically. That is a good historical Q that I will have to look up. I've done a bit of archaeology on where we got CScriptNum from (I believe openSSL, still confirmign though).
 2217:07 <glozow> oo, scandalous
 2317:08 <Chris_Stewart_5> stickies-v: I've previously assumed it was that way because Satoshi said so, but I need to confirm that :-)
 2417:08 <ion-> I get that not all processors were 64bit at the time
 2517:08 <Chris_Stewart_5> ion-: Fair point.
 2617:08 <ion-> Are there any security issues with 64bit nowdays? I cannot think of any
 2717:09 <stickies-v> what do you mean with security issues with 64 bit?
 2817:09 <Chris_Stewart_5> ion-: Thats why I've got you fine folks to review my (and the elements' team) work. This implementation is mostly pulled over from the elements blockchain: https://github.com/ElementsProject/elements/
 2917:10 <Chris_Stewart_5> that is the implementation that powers the liquid sidechain and is a fork of bitcoin's codebase.
 3017:10 <Chris_Stewart_5> They implemented this 64bit math and the error handling capabilities for the 64bit instructions over there ~2-3 years ago.
 3117:12 <abubakarsadiq> hi
 3217:12 <ion-> There shouldn't be anything against using 64bit arithmetic. It makes better sense. This is what I am trying to say in a conservative way.
 3317:13 <Chris_Stewart_5> stickies-v: This might answer your 'why 32 bits' question.
 3417:13 <Chris_Stewart_5> Can anyone answer this question? 'What does the CScriptNum nMaxNumSize parameter do?'
 3517:14 <stickies-v> it represents the max size of the stack item we're evaluating
 3617:16 <stickies-v> do we ever change nMaxNumSize, btw? or is it always 4?
 3717:16 <Chris_Stewart_5> and what happens if the stack item is larger than nMaxNumSize?
 3817:16 <stickies-v> throw scriptnum_error
 3917:16 <abubakarsadiq> to limit the opcode numerical operation on `nMaxNumSize ` number of bytes
 4017:16 <Chris_Stewart_5> abubakarsadiq: Yes!
 4117:16 <Chris_Stewart_5> stickies-v: :+1:
 4217:16 <Chris_Stewart_5> Ok, so now i'm going to skip ahead on our questions to the next logical one
 4317:16 <abubakarsadiq> why scriptnum_error not just push `False`?
 4417:17 <Chris_Stewart_5> Its a bit more difficult, but if you look through interpreter.cpp you should find it
 4517:17 <Chris_Stewart_5> 'What 2 opcodes accept 5 byte numeric inputs?'
 4617:17 <Chris_Stewart_5> abubakarsadiq: I like how you think! That is what my 64bit PR does :-). Check out the
 4717:18 <stickies-v> yeah just found one: https://github.com/bitcoin/bitcoin/blob/b50554babdddf452acaa51bac757736766c70e81/src/script/interpreter.cpp#L545
 4817:18 <Chris_Stewart_5> abubakarsadiq: 'How arithmetic works with #29221' section on this page: https://bitcoincore.reviews/29221
 4917:18 <stickies-v> OP_CHECKLOCKTIMEVERIFY and OP_CHECKSEQUENCEVERIFY
 5017:19 <Chris_Stewart_5> stickies-v: Yes! So the key point here is we already have carve outs for specific opcodes we have implemented that are time sensitive. For others following along, I recommend reading the comments in the c++ codebase for 'why we need 5 byte inputs for numbers related to time'
 5117:19 <abubakarsadiq> Also I have a question while reading the BIP why are we introducing new opcodes that does the same thing with current opcodes but with 64 bit values, why not just upgrade the old ones to support both 32 and 64 bit?
 5217:20 <abubakarsadiq> thanks for the link @Chris_Stewart_5
 5317:20 <Chris_Stewart_5> abubakarsadiq: Ok this is a great question, and i'm currently prototyping an implementation that does just this. I've been asking myself the same question lately that it may not be necessary to make new opcodes, rather re-purpose old ones.
 5417:21 <Chris_Stewart_5> abubakarsadiq: I'm going to table discussion on that topic for now as it is not what is in #29221, but if you would like to see what that looks like follow my work on this branch: https://github.com/Christewart/bitcoin/tree/64bit-arith-implicit
 5517:22 <Chris_Stewart_5> So back to CScriptNum -- which is the data structure we use to represent numbers in Script currently
 5617:22 <abubakarsadiq> I will like that! and thanks for the link again
 5717:22 <Chris_Stewart_5> Can anyone answer this question: 'Why was the fRequireMinimal flag introduced to CScriptNum?'
 5817:22 <stickies-v> (those seem to be the only 2 use cases btw, everything compiles when patching those 2 lines in OP_CHECKLOCKTIMEVERIFY and OP_CHECKSEQUENCEVERIFY and then removing nMaxNumSize altogether)
 5917:23 <glozow> PR 5065 links to BIP62, which mentions "zero-padded number pushes" as a source of malleability. So we require minimal representation i.e. no zero-padding
 6017:24 <Chris_Stewart_5> stickies-v: yes :-)
 6117:24 <Chris_Stewart_5> glozow: Yes! Before hand we were vulnerable to malleability attacks. Since CScriptNum has a _variable length_ encoding, numbers can be represented multiple ways
 6217:25 <Chris_Stewart_5> for instance, zero can be encoded as [], 0x00, 0x0000, 0x0000000 and so forth. There also is a negative zero :-)
 6317:26 <abubakarsadiq> Negative zero?
 6417:26 <Chris_Stewart_5> A wise guy on on the p2p network would modify your zero encoding and change your txid making you (potentially) lose track of your transaction. This is allegedly what took MtGox down
 6517:27 <Chris_Stewart_5> abubakarsadiq: yes sir! https://github.com/bitcoin/bitcoin/blob/b50554babdddf452acaa51bac757736766c70e81/src/script/script.h#L256
 6617:27 <Chris_Stewart_5> So malleability is obviously a big problem, and this was partially addressed by segwit, but here is the next question
 6717:27 <Chris_Stewart_5> 'Is #29221 malleability safe? Why?'
 6817:30 <stickies-v> I think it is because we ensure for a size of 8 bytes here? https://github.com/bitcoin/bitcoin/pull/29221/files#diff-a0337ffd7259e8c7c9a7786d6dbd420c80abfa1afdb34ebae3261109d9ae3c19R1253
 6917:31 <stickies-v> (both operands)
 7017:31 <Chris_Stewart_5> stickies-v: Exactly. The current implementation in #29221 uses a _fixed length_ number encoding rather than a _variable length_ number encoding used by CScriptNum
 7117:31 <Chris_Stewart_5> The fixed length is 8 bytes.
 7217:32 <Chris_Stewart_5> Some pushback that I have got on this proposal is that it will increase the blockchain size. If you want to read more about my rebuttal to that please see this link: https://delvingbitcoin.org/t/64-bit-arithmetic-soft-fork/397/34?u=chris_stewart_5
 7317:33 <stickies-v> what's the rationale behind the fixed length approach? is it mostly to make implementation simpler (and thus more bug-proof), at the cost of (slightly? i don't know) higher scripts?
 7417:33 <stickies-v> *bigger scripts, not higher
 7517:33 <Chris_Stewart_5> stickies-v: Exactly. In raw bytes, Scripts will be larger. I did an analysis of the mainnet blockchain and found that it would be ~1GB (0.17%) larger if this proposal was enacted from the genesis block.
 7617:34 <stickies-v> and did you try implementing it with var length too? is it a big diff?
 7717:35 <glozow> would that just entail creating `CScriptNum`s with `nMaxNumSize=8`?
 7817:35 <Chris_Stewart_5> stickies-v: I have implemented 64bit arithmetic with CScriptNum (and thus giving us a variable length), you can find the branch here if you are curious: https://github.com/Christewart/bitcoin/tree/64bit-arith-cscriptnum
 7917:36 <stickies-v> cool, thanks!
 8017:36 <Chris_Stewart_5> stickies-v: In terms of analyzing blockchain usage patterns, it is difficult to say what the size increase would be. It would be smaller than the fixed length proposal, but since we cannot use 8 byte CScriptNums, we can't make assumptions about how much larger the chain would be since genesis
 8117:37 <Chris_Stewart_5> glozow: yes.
 8217:37 <glozow> Would it not be simpler to just use `CScriptNum` everywhere...?
 8317:37 <Chris_Stewart_5> and this is a great segway to my next question
 8417:38 <Chris_Stewart_5> This question is a bit more involved, so I"m going to paste it into this IRC chat with the Script example
 8517:38 <Chris_Stewart_5> Q: The Script in the Explicit conversion op codes section will not work. Can you guess why? Hint: it has something to do with OP_LE64TOSCRIPTNUM.
 8617:38 <Chris_Stewart_5> Script: 0x000e876481700000 0x000e876481700000 OP_ADD64 OP_DROP OP_LE64TOSCRIPTNUM OP_SIZE OP_8 OP_EQUALVERIFY OP_SCRIPTNUMTOLE64 0x001d0ed902e00000 OP_EQUAL
 8717:39 <Chris_Stewart_5> Look closely at OP_LE64TOSCRIPTNUM, here is the link to the impl for convinience: https://github.com/bitcoin/bitcoin/blob/bc772fe8f4ab37d97bfd68a47b67a92a45ac494a/src/script/interpreter.cpp#L1353
 8817:40 <Chris_Stewart_5> I'll give another hint in a couple minutes if no one sees it
 8917:40 <glozow> is that value larger than what can be held?
 9017:40 <Chris_Stewart_5> glozow: Yes! Because of what parameter?
 9117:40 <glozow> nDefaultMaxNumSize
 9217:41 <glozow> ?
 9317:41 <Chris_Stewart_5> glozow: Yes! So this presents a fundamental problem with the design of this PR currently, and is why i'm working on alternative designs
 9417:42 <Chris_Stewart_5> The problem is, 'How do I get the _new_ number format that supports 8 bytes to interop with legacy opcodes that only support 4 bytes (5 bytes in the case of the locktime op codes)
 9517:43 <stickies-v> should OP_LE64TOSCRIPTNUM also return a tuple to be consistent with the other new opcodes?
 9617:43 <Chris_Stewart_5> This is why I have an alternative implementation of OP_SIZE -- as an example -- in the webpage
 9717:43 <stickies-v> (true/false for success)
 9817:44 <Chris_Stewart_5> stickies-v: That is a great idea. Although it doesn't solve the fundamental problem, it at least introduces error handling capability
 9917:45 <stickies-v> yeah and i'm also intuitively not a huge fan of all these op_drops we now require, but i've only had a rather cursory look
10017:45 <Chris_Stewart_5> stickies-v: If you were writing a production Script, the OP_DROP should be replaced with OP_IF OP_ELSE and then you handle the failure case in the OP_ELSE clause. Since these are demo Scripts I cheated a bit :-)
10117:47 <Chris_Stewart_5> Do people understand the fundamental problem introduced by a _larger_ (8 bytes, in our case) number format than the existing number format (4 bytes, occasionally 5 bytes)? This is a really key point and i'm happy to answer any more questions on the topic since it is absolutely crucial to understand imo
10217:49 <stickies-v> sorry - unrelated question, is there any debate around whether these new opcodes should indeed return a success code? is this something that's absolutely required for the proposal to work?
10317:50 <Chris_Stewart_5> stickies-v: Do you mean pushing true/false onto the stack to indicate success/failure of the opcode?
10417:50 <stickies-v> yeah
10517:50 <stickies-v> i think it's nice that we allow scripts to gracefully handle overflows btw, but the downside is that we're forcing the cost even for scripts that don't require it, so just wondering how essential that is?
10617:51 <Chris_Stewart_5> stickies-v: I don't think its absolutely required, no. It provides better developer ergonomics, imo. But that is a personal preference ig. FWIW, that was a design choice I pulled over from elements.
10717:52 <stickies-v> cool, thanks
10817:52 <Chris_Stewart_5> np :-)
10917:53 <Chris_Stewart_5> This Q was already somewhat addressed by abubakarsadiq
11017:53 <Chris_Stewart_5> Q: What should we do with the old opcodes (OP_ADD, OP_SUB)?
11117:54 <Guest93> I didn't realise we were replacing the opcodes
11217:56 <Chris_Stewart_5> Guest93: So we are currently allocating new opcodes, you see them here: https://github.com/bitcoin/bitcoin/blob/bc772fe8f4ab37d97bfd68a47b67a92a45ac494a/src/script/script.h#L213
11317:56 <stickies-v> if we're modifying the current opcodes to allow both 32 and 64 bit arithmetic, as suggested earlier, perhaps we can skip the success codes and _potentially_ add OP_ADDSAFE, OP_SUBSAFE etc opcodes in the future if there's developer demand?
11417:57 <stickies-v> s/developer/user
11517:57 <Chris_Stewart_5> stickies-v: I don't believe it is necessary to retain _old_ semantics for _new_ soft forks. I'm working on this implementation, so I haven't 100% confirmed it yet.
11617:58 <Chris_Stewart_5> For instance, pre TAPSCRIPT_64BIT we throw exceptions when there is overflows with OP_ADD, but if `sigversion == SigVersion::TAPSCRIPT_64BIT` we can redefine semantics to push true/false onto the stack, accept upto 8 byte numeric inputs etc
11717:59 <Chris_Stewart_5> to add even more fuel to the fire, I believe (again, haven't coded to confirm) that we could use this same mechanism to extend OP_ADD in the future to accept even bigger inputs, such as 256bit scalars. This is something that people are already wanting it seems on delvingbitcoin
11818:00 <Chris_Stewart_5> Thank you everyone for coming out and asking GREAT questions. I'm happy to keep the convo going on irc, twitter, github etc. Don't hesitate to reach out!
11918:00 <stickies-v> i'm very unfamiliar with script, so apologies if this doesn't make sense, but i guess where i'm getting at is it seems like this PR is trying to do 2 things: introduce 64 bit arithmetic, and allow scripts to handle overflows through success codes, and maybe it's better to do those separately and just do 64 bit here?
12018:01 <glozow> thanks so much Chris_Stewart_5, learned a lot!
12118:01 <stickies-v> thanks for hosting and writing out such extensive notes Chris_Stewart_5 , learned a lot today!
12218:01 <Chris_Stewart_5> stickies-v: That is a very reasonable take. Unfortunately with the pace we deploy soft forks (every ~4 years), you have a tendency to want to cram as much in as possible :-)
12318:01 <Chris_Stewart_5> #endmeeting