From 53b347ee2a9249e54a4aaad65daa10f8b3014a76 Mon Sep 17 00:00:00 2001 From: msinkec Date: Mon, 1 Jun 2026 10:26:09 +0200 Subject: [PATCH] make OP_CODESEPERATOR work in an arkade script context, emulating BIP342 behaviour --- pkg/arkade/engine.go | 16 +--- pkg/arkade/opcode.go | 21 ++--- pkg/arkade/script.go | 3 - pkg/arkade/script_test.go | 143 ++++++++++++++++++++++++++++-- pkg/arkade/sigvalidate.go | 14 ++- test/signed_pay_to_output_test.go | 4 +- 6 files changed, 158 insertions(+), 43 deletions(-) diff --git a/pkg/arkade/engine.go b/pkg/arkade/engine.go index b435208..657a348 100644 --- a/pkg/arkade/engine.go +++ b/pkg/arkade/engine.go @@ -30,9 +30,9 @@ const ( ) const ( - // blankCodeSepValue is the value of the code separator position in the - // tapscript sighash when no code separator was found in the script. - blankCodeSepValue = math.MaxUint32 + // BlankCodeSepValue is the value of the code separator position in the + // tapscript sighash when no code separator was executed in the script. + BlankCodeSepValue uint32 = math.MaxUint32 ) // taprootExecutionCtx houses the special context-specific information we need @@ -49,8 +49,6 @@ type taprootExecutionCtx struct { sigOpsBudget int32 mustSucceed bool - - trackCodeSep bool } // sigOpsDelta is both the starting budget for sig ops for tapscript @@ -75,9 +73,8 @@ func (t *taprootExecutionCtx) tallysigOp() error { // context. func newTaprootExecutionCtx(inputWitnessSize int32) *taprootExecutionCtx { return &taprootExecutionCtx{ - codeSepPos: blankCodeSepValue, + codeSepPos: BlankCodeSepValue, sigOpsBudget: sigOpsDelta + inputWitnessSize, - trackCodeSep: true, } } @@ -140,9 +137,6 @@ type Engine struct { // the current program counter. Note that it differs from the actual byte // index into the script and is really only used for disassembly purposes. // - // lastCodeSep specifies the position within the current script of the last - // OP_CODESEPARATOR. - // // tokenizer provides the token stream of the current script being executed // and doubles as state tracking for the program counter within the script. // @@ -157,7 +151,6 @@ type Engine struct { scripts [][]byte scriptIdx int opcodeIdx int - lastCodeSep int tokenizer ScriptTokenizer dstack stack astack stack @@ -660,7 +653,6 @@ func (vm *Engine) Step() (done bool, err error) { vm.scriptIdx++ } - vm.lastCodeSep = 0 if vm.scriptIdx >= len(vm.scripts) { return true, nil } diff --git a/pkg/arkade/opcode.go b/pkg/arkade/opcode.go index 86999f8..311992d 100644 --- a/pkg/arkade/opcode.go +++ b/pkg/arkade/opcode.go @@ -1751,8 +1751,8 @@ func opcodeHash256(op *opcode, data []byte, vm *Engine) error { return nil } -// opcodeCodeSeparator stores the current script offset as the most recently -// seen OP_CODESEPARATOR which is used during signature checking. +// opcodeCodeSeparator stores the current opcode position as the most recently +// executed OP_CODESEPARATOR for BIP342-style signature hashing. // // This opcode does not change the contents of the data stack. func opcodeCodeSeparator(op *opcode, data []byte, vm *Engine) error { @@ -1762,10 +1762,7 @@ func opcodeCodeSeparator(op *opcode, data []byte, vm *Engine) error { return scriptError(txscript.ErrReservedOpcode, str) } - vm.lastCodeSep = int(vm.tokenizer.ByteIndex()) - if vm.taprootCtx.trackCodeSep { - vm.taprootCtx.codeSepPos = uint32(vm.tokenizer.OpcodePosition()) - } + vm.taprootCtx.codeSepPos = uint32(vm.tokenizer.OpcodePosition()) return nil } @@ -1774,14 +1771,10 @@ func opcodeCodeSeparator(op *opcode, data []byte, vm *Engine) error { // signature and replaces them with a bool which indicates if the signature was // successfully verified. // -// The process of verifying a signature requires calculating a signature hash in -// the same way the transaction signer did. It involves hashing portions of the -// transaction based on the hash type byte (which is the final byte of the -// signature) and the portion of the script starting from the most recent -// OP_CODESEPARATOR (or the beginning of the script if there are none) to the -// end of the script (with any other OP_CODESEPARATORs removed). Once this -// "script hash" is calculated, the signature is checked using standard -// cryptographic methods against the provided public key. +// The process of verifying a signature requires calculating the arkade +// tapscript signature hash in the same way the transaction signer did. The +// digest commits to the spending tapleaf hash and the opcode position of the +// last executed OP_CODESEPARATOR, or BlankCodeSepValue if none executed. // // Stack transformation: [... signature pubkey] -> [... bool] func opcodeCheckSig(op *opcode, data []byte, vm *Engine) error { diff --git a/pkg/arkade/script.go b/pkg/arkade/script.go index 1a76c32..7cd3a72 100644 --- a/pkg/arkade/script.go +++ b/pkg/arkade/script.go @@ -151,9 +151,6 @@ func (s *ArkadeScript) Execute(spendingTx *wire.MsgTx, prevOutFetcher ArkPrevOut engine.taprootCtx = newTaprootExecutionCtxForLeaf( s.spendingTapLeaf, int32(s.witness.SerializeSize()), ) - // Arkade scripts execute from the emulator packet, not from the - // spending tapleaf whose hash the sighash commits to. - engine.taprootCtx.trackCodeSep = false for _, opt := range opts { opt(engine) diff --git a/pkg/arkade/script_test.go b/pkg/arkade/script_test.go index 78bf515..87435ff 100644 --- a/pkg/arkade/script_test.go +++ b/pkg/arkade/script_test.go @@ -57,9 +57,9 @@ func TestArkadeScriptExecuteUsesSpendingTapLeafForSighash(t *testing.T) { txscript.NewMultiPrevOutFetcher(prevOuts), nil, nil, ) sighashes := txscript.NewTxSigHashes(tx, prevOutFetcher) - digest, err := CalcTapscriptSignaturehash( + digest, err := CalcArkadeScriptSignatureHash( sighashes, txscript.SigHashDefault, tx, 0, prevOutFetcher, - closureTapLeaf, + closureTapLeaf, BlankCodeSepValue, ) require.NoError(t, err) @@ -74,7 +74,7 @@ func TestArkadeScriptExecuteUsesSpendingTapLeafForSighash(t *testing.T) { require.NoError(t, script.Execute(tx, prevOutFetcher, 0)) } -func TestArkadeScriptExecuteDoesNotUsePacketCodeSeparatorForSighash(t *testing.T) { +func TestArkadeScriptExecuteUsesCodeSeparatorForSighash(t *testing.T) { t.Parallel() signingKey, _ := btcec.PrivKeyFromBytes([]byte{ @@ -84,6 +84,9 @@ func TestArkadeScriptExecuteDoesNotUsePacketCodeSeparatorForSighash(t *testing.T 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, }) pubKeyX := schnorr.SerializePubKey(signingKey.PubKey()) + // OP_CODESEPARATOR is the first opcode (position 0). Per BIP342 it sets + // codesep_pos to its own opcode position, which the following OP_CHECKSIG + // must commit to. arkadeScript, err := txscript.NewScriptBuilder(). AddOp(OP_CODESEPARATOR). AddData(pubKeyX). @@ -111,21 +114,145 @@ func TestArkadeScriptExecuteDoesNotUsePacketCodeSeparatorForSighash(t *testing.T txscript.NewMultiPrevOutFetcher(prevOuts), nil, nil, ) sighashes := txscript.NewTxSigHashes(tx, prevOutFetcher) - digest, err := CalcTapscriptSignaturehash( + + const codeSepPos = uint32(0) // OP_CODESEPARATOR is opcode 0 in the script. + + // A signature that commits to the executed code-separator position must + // verify. + digest, err := CalcArkadeScriptSignatureHash( sighashes, txscript.SigHashDefault, tx, 0, prevOutFetcher, - spendingTapLeaf, + spendingTapLeaf, codeSepPos, ) require.NoError(t, err) - sig, err := schnorr.Sign(signingKey, digest) require.NoError(t, err) - script := &ArkadeScript{ script: arkadeScript, witness: wire.TxWitness{sig.Serialize()}, spendingTapLeaf: spendingTapLeaf, } - require.NoError(t, script.Execute(tx, prevOutFetcher, 0)) + require.NoError(t, script.Execute(tx, prevOutFetcher, 0), + "signature committing to the executed codesep position must verify") + + // A signature that ignores the code separator (blank codesep_pos, the + // pre-BIP342 behavior) must now be rejected. + staleDigest, err := CalcArkadeScriptSignatureHash( + sighashes, txscript.SigHashDefault, tx, 0, prevOutFetcher, + spendingTapLeaf, BlankCodeSepValue, + ) + require.NoError(t, err) + staleSig, err := schnorr.Sign(signingKey, staleDigest) + require.NoError(t, err) + staleScript := &ArkadeScript{ + script: arkadeScript, + witness: wire.TxWitness{staleSig.Serialize()}, + spendingTapLeaf: spendingTapLeaf, + } + require.Error(t, staleScript.Execute(tx, prevOutFetcher, 0), + "signature ignoring the code separator must fail") +} + +func TestArkadeScriptExecuteUpdatesCodeSepPosOnCodeSeparator(t *testing.T) { + t.Parallel() + + // OP_CODESEPARATOR is opcode 0; OP_TRUE leaves a truthy stack so execution + // completes successfully and we can observe codesep_pos at each step. + arkadeScript := []byte{OP_CODESEPARATOR, OP_TRUE} + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x04}, Index: 0} + tx := &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: outpoint, + Sequence: 0xffffffff, + }}, + TxOut: []*wire.TxOut{{ + Value: 900, + PkScript: []byte{OP_TRUE}, + }}, + } + prevOuts := map[wire.OutPoint]*wire.TxOut{ + outpoint: {Value: 1_000, PkScript: []byte{OP_1, 0x20}}, + } + prevOutFetcher := newTestArkPrevOutFetcher( + txscript.NewMultiPrevOutFetcher(prevOuts), nil, nil, + ) + + script := &ArkadeScript{ + script: arkadeScript, + spendingTapLeaf: txscript.NewBaseTapLeaf([]byte{OP_TRUE}), + } + + var seen []uint32 + err := script.Execute(tx, prevOutFetcher, 0, + WithDebugCallback(func(_ *StepInfo, e *Engine) error { + seen = append(seen, e.taprootCtx.codeSepPos) + return nil + }), + ) + require.NoError(t, err) + + // The callback fires once for the initial state, then after each step. + require.GreaterOrEqual(t, len(seen), 2) + require.Equal(t, BlankCodeSepValue, seen[0], + "codesep_pos must start at the blank sentinel") + require.Equal(t, uint32(0), seen[1], + "codesep_pos must equal the OP_CODESEPARATOR opcode position after it executes") +} + +func TestArkadeScriptExecuteOpSighashUsesCodeSeparatorPosition(t *testing.T) { + t.Parallel() + + spendingTapLeaf := txscript.NewBaseTapLeaf([]byte{OP_TRUE}) + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x05}, Index: 0} + tx := &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: outpoint, + Sequence: 0xffffffff, + }}, + TxOut: []*wire.TxOut{{ + Value: 900, + PkScript: []byte{OP_TRUE}, + }}, + } + prevOuts := map[wire.OutPoint]*wire.TxOut{ + outpoint: {Value: 1_000, PkScript: []byte{OP_1, 0x20}}, + } + prevOutFetcher := newTestArkPrevOutFetcher( + txscript.NewMultiPrevOutFetcher(prevOuts), nil, nil, + ) + sighashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + + expectedDigest, err := CalcArkadeScriptSignatureHash( + sighashes, txscript.SigHashDefault, tx, 0, prevOutFetcher, + spendingTapLeaf, 0, + ) + require.NoError(t, err) + blankDigest, err := CalcArkadeScriptSignatureHash( + sighashes, txscript.SigHashDefault, tx, 0, prevOutFetcher, + spendingTapLeaf, BlankCodeSepValue, + ) + require.NoError(t, err) + require.NotEqual(t, blankDigest, expectedDigest, + "test requires the code separator position to affect OP_SIGHASH") + + arkadeScript, err := txscript.NewScriptBuilder(). + AddOp(OP_CODESEPARATOR). + AddOp(OP_0). + AddOp(OP_SIGHASH). + AddData(expectedDigest). + AddOp(OP_EQUAL). + Script() + require.NoError(t, err) + + script := &ArkadeScript{ + script: arkadeScript, + spendingTapLeaf: spendingTapLeaf, + } + require.NoError(t, script.Execute(tx, prevOutFetcher, 0), + "OP_SIGHASH must include the last executed OP_CODESEPARATOR position") } func TestReadArkadeScriptRejectsNonBaseSpendingTapLeafVersion(t *testing.T) { diff --git a/pkg/arkade/sigvalidate.go b/pkg/arkade/sigvalidate.go index 71ac7de..8395275 100644 --- a/pkg/arkade/sigvalidate.go +++ b/pkg/arkade/sigvalidate.go @@ -73,19 +73,22 @@ func computeArkadeSighash(vm *Engine, return digest[:], nil } -// CalcTapscriptSignaturehash returns the non-standard arkade tapscript +// CalcArkadeScriptSignatureHash returns the non-standard arkade tapscript // signature hash used by OP_CHECKSIG and OP_SIGHASH inside arkade scripts. // // The byte layout mirrors BIP342's tapscript sigMsg with arkade's witness // masking and "ArkadeTapSighash" final tag. Callers should pass the active -// Bitcoin spending tapleaf whose hash the signature commits to. -func CalcTapscriptSignaturehash( +// Bitcoin spending tapleaf whose hash the signature commits to. Pass +// BlankCodeSepValue when no OP_CODESEPARATOR executed before the signature +// opcode. +func CalcArkadeScriptSignatureHash( sigHashes *txscript.TxSigHashes, hashType txscript.SigHashType, tx *wire.MsgTx, idx int, prevOutFetcher ArkPrevOutFetcher, tapLeaf txscript.TapLeaf, + codeSepPos uint32, ) ([]byte, error) { if sigHashes == nil { return nil, fmt.Errorf("nil sighash cache") @@ -97,12 +100,15 @@ func CalcTapscriptSignaturehash( return nil, fmt.Errorf("nil prevout fetcher") } + taprootCtx := newTaprootExecutionCtxForLeaf(tapLeaf, 0) + taprootCtx.codeSepPos = codeSepPos + vm := &Engine{ tx: *tx, txIdx: idx, hashCache: sigHashes, prevOutFetcher: prevOutFetcher, - taprootCtx: newTaprootExecutionCtxForLeaf(tapLeaf, 0), + taprootCtx: taprootCtx, } return computeArkadeSighash(vm, hashType) diff --git a/test/signed_pay_to_output_test.go b/test/signed_pay_to_output_test.go index aaaeaa5..eb9ee09 100644 --- a/test/signed_pay_to_output_test.go +++ b/test/signed_pay_to_output_test.go @@ -228,9 +228,9 @@ func signArkadeInput( PrevOutputFetcher: txscript.NewMultiPrevOutFetcher(prevouts), } sighashes := txscript.NewTxSigHashes(ptx.UnsignedTx, prevOutFetcher) - message, err := arkade.CalcTapscriptSignaturehash( + message, err := arkade.CalcArkadeScriptSignatureHash( sighashes, txscript.SigHashDefault, ptx.UnsignedTx, - inputIndex, prevOutFetcher, tapLeaf, + inputIndex, prevOutFetcher, tapLeaf, arkade.BlankCodeSepValue, ) require.NoError(t, err)