diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8b674e..92dcb1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,46 @@ jobs: - name: Run linter run: nix develop --command make lint + + conformance: + name: Conformance Tests + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin_arm64 + - os: macos-13 + platform: darwin_amd64 + - os: ubuntu-latest + platform: linux_amd64 + - os: ubuntu-24.04-arm + platform: linux_arm64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.12' + + - name: Install dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libboost-all-dev + + - name: Install dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install boost + + - name: Build Kernel + run: make build-kernel + + - name: Run conformance tests + working-directory: cmd/conformance-handler + run: make test diff --git a/cmd/conformance-handler/.gitignore b/cmd/conformance-handler/.gitignore new file mode 100644 index 0000000..0426669 --- /dev/null +++ b/cmd/conformance-handler/.gitignore @@ -0,0 +1,2 @@ +.conformance-tests +handler \ No newline at end of file diff --git a/cmd/conformance-handler/Makefile b/cmd/conformance-handler/Makefile new file mode 100644 index 0000000..4957cc5 --- /dev/null +++ b/cmd/conformance-handler/Makefile @@ -0,0 +1,79 @@ +# Conformance Handler Makefile + +# Test suite configuration +TEST_VERSION := 0.0.2 +TEST_REPO := stringintech/kernel-bindings-tests +TEST_DIR := .conformance-tests + +# Platform detection +UNAME_S := $(shell uname -s) +UNAME_M := $(shell uname -m) + +ifeq ($(UNAME_S),Darwin) + ifeq ($(UNAME_M),arm64) + PLATFORM := darwin_arm64 + else + PLATFORM := darwin_amd64 + endif +else ifeq ($(UNAME_S),Linux) + ifeq ($(UNAME_M),x86_64) + PLATFORM := linux_amd64 + else ifeq ($(UNAME_M),aarch64) + PLATFORM := linux_arm64 + else + PLATFORM := linux_amd64 + endif +else + $(error Unsupported platform: $(UNAME_S) $(UNAME_M)) +endif + +# Binary names +TEST_RUNNER := $(TEST_DIR)/runner +HANDLER_BIN := handler + +.PHONY: all build download-tests test clean help + +all: build test + +help: + @echo "Conformance Handler Makefile" + @echo "" + @echo "Targets:" + @echo " build - Build the conformance handler binary" + @echo " download-tests - Download the test suite for your platform" + @echo " test - Run conformance tests against the handler" + @echo " clean - Remove built binaries and downloaded tests" + @echo " help - Show this help message" + @echo "" + @echo "Configuration:" + @echo " Test Version: $(TEST_VERSION)" + @echo " Platform: $(PLATFORM)" + +build: + @echo "Building conformance handler..." + go build -o $(HANDLER_BIN) . + +download-tests: + @echo "Downloading test suite $(TEST_VERSION) for $(PLATFORM)..." + @mkdir -p $(TEST_DIR) + $(eval DOWNLOAD_URL := https://github.com/$(TEST_REPO)/releases/download/v$(TEST_VERSION)/kernel-bindings-tests_$(TEST_VERSION)_$(PLATFORM).tar.gz) + @echo "URL: $(DOWNLOAD_URL)" + @curl -L -o $(TEST_DIR)/test-runner.tar.gz "$(DOWNLOAD_URL)" + @echo "Extracting test runner..." + @tar -xzf $(TEST_DIR)/test-runner.tar.gz -C $(TEST_DIR) + @chmod +x $(TEST_RUNNER) + @rm $(TEST_DIR)/test-runner.tar.gz + @echo "Test runner downloaded to $(TEST_RUNNER)" + +test: build + @if [ ! -f "$(TEST_RUNNER)" ]; then \ + echo "Test runner not found. Downloading..."; \ + $(MAKE) download-tests; \ + fi + @echo "Running conformance tests..." + $(TEST_RUNNER) --handler ./$(HANDLER_BIN) + +clean: + @echo "Cleaning up..." + rm -f $(HANDLER_BIN) + rm -rf $(TEST_DIR) \ No newline at end of file diff --git a/cmd/conformance-handler/README.md b/cmd/conformance-handler/README.md new file mode 100644 index 0000000..9b2aee0 --- /dev/null +++ b/cmd/conformance-handler/README.md @@ -0,0 +1,33 @@ +# Conformance Handler + +This binary implements the JSON protocol required by the [kernel-bindings-spec](https://github.com/stringintech/kernel-bindings-spec) conformance testing framework. + +## Purpose + +The conformance handler acts as a bridge between the test runner and the Go Bitcoin Kernel bindings. It: + +- Reads test requests from stdin (JSON protocol) +- Executes operations using the Go binding API +- Returns responses to stdout (JSON protocol) + +## Testing + +This handler is designed to work with the conformance test suite. The easiest way to run tests is using the Makefile: + +```bash +# Run conformance tests (builds handler and downloads test runner automatically) +make test + +# Or manually build and run +make build +make download-tests +./.conformance-tests/runner --handler ./handler +``` + +The test suite is automatically downloaded for your platform (darwin_arm64, darwin_amd64, linux_amd64, or linux_arm64). + +## Pinned Test Version + +This handler is compatible with: +- Test Suite Version: `0.0.2` +- Test Repository: [stringintech/kernel-bindings-tests](https://github.com/stringintech/kernel-bindings-tests) \ No newline at end of file diff --git a/cmd/conformance-handler/handler.go b/cmd/conformance-handler/handler.go new file mode 100644 index 0000000..3f16a4b --- /dev/null +++ b/cmd/conformance-handler/handler.go @@ -0,0 +1,19 @@ +package main + +import "fmt" + +// handleRequest dispatches a request to the appropriate handler +func handleRequest(req Request) (resp Response) { + defer func() { + if r := recover(); r != nil { + resp = NewHandlerErrorResponse(req.ID, "INTERNAL_ERROR", fmt.Sprintf("%v", r)) + } + }() + + switch req.Method { + case "btck_script_pubkey_verify": + return handleScriptPubkeyVerify(req) + default: + return NewHandlerErrorResponse(req.ID, "METHOD_NOT_FOUND", "") + } +} diff --git a/cmd/conformance-handler/main.go b/cmd/conformance-handler/main.go new file mode 100644 index 0000000..933a6a1 --- /dev/null +++ b/cmd/conformance-handler/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" +) + +func main() { + // Read requests from stdin line by line + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := scanner.Text() + + // Parse request + var req Request + if err := json.Unmarshal([]byte(line), &req); err != nil { + sendResponse(NewHandlerErrorResponse("", "INVALID_REQUEST", "")) + continue + } + + resp := handleRequest(req) + sendResponse(resp) + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err) + os.Exit(1) + } +} + +// sendResponse writes a response to stdout as JSON +func sendResponse(resp Response) { + data, err := json.Marshal(resp) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling response: %v\n", err) + return + } + + fmt.Println(string(data)) +} diff --git a/cmd/conformance-handler/protocol.go b/cmd/conformance-handler/protocol.go new file mode 100644 index 0000000..6d83a05 --- /dev/null +++ b/cmd/conformance-handler/protocol.go @@ -0,0 +1,59 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +type Request struct { + ID string `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type Response struct { + ID string `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +type Error struct { + Code ErrorCode `json:"code"` +} + +type ErrorCode struct { + Type string `json:"type"` + Member string `json:"member"` +} + +// NewErrorResponse creates an error response with the given code type and member. +// Use directly for C API error codes (e.g., "btck_ScriptVerifyStatus"). +// For handler errors, use NewHandlerErrorResponse. +func NewErrorResponse(id, codeType, codeMember string) Response { + return Response{ + ID: id, + Error: &Error{ + Code: ErrorCode{ + Type: codeType, + Member: codeMember, + }, + }, + } +} + +// NewHandlerErrorResponse creates an error response for handler layer errors. +// Use for request validation, method routing, and parameter parsing errors. +// Optional detail parameter adds context to the error (e.g., "INVALID_PARAMS (missing field 'foo')"). +func NewHandlerErrorResponse(id, codeMember, detail string) Response { + member := codeMember + if detail != "" { + member += fmt.Sprintf(" (%s)", detail) + } + return NewErrorResponse(id, "Handler", member) +} + +// NewInvalidParamsResponse creates an INVALID_PARAMS error with optional detail. +// Use when request parameters are malformed or missing. Detail provides context about the issue. +func NewInvalidParamsResponse(id, detail string) Response { + return NewHandlerErrorResponse(id, "INVALID_PARAMS", detail) +} diff --git a/cmd/conformance-handler/script_pubkey.go b/cmd/conformance-handler/script_pubkey.go new file mode 100644 index 0000000..69c65cc --- /dev/null +++ b/cmd/conformance-handler/script_pubkey.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/stringintech/go-bitcoinkernel/kernel" +) + +// handleScriptPubkeyVerify verifies a script against a transaction +func handleScriptPubkeyVerify(req Request) Response { + var params struct { + ScriptPubkeyHex string `json:"script_pubkey"` + Amount int64 `json:"amount"` + TxToHex string `json:"tx_to"` + InputIndex uint `json:"input_index"` + Flags json.RawMessage `json:"flags"` + SpentOutputs []SpentOutput `json:"spent_outputs"` + } + + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return NewInvalidParamsResponse(req.ID, "") + } + + // Decode script pubkey + var scriptBytes []byte + var err error + if params.ScriptPubkeyHex != "" { + scriptBytes, err = hex.DecodeString(params.ScriptPubkeyHex) + if err != nil { + return NewInvalidParamsResponse(req.ID, "script pubkey hex") + } + } + + // Decode transaction + txBytes, err := hex.DecodeString(params.TxToHex) + if err != nil { + return NewInvalidParamsResponse(req.ID, "transaction hex") + } + + // Parse flags + flags, err := parseScriptFlags(params.Flags) + if err != nil { + return NewInvalidParamsResponse(req.ID, "flags") + } + + // Parse spent outputs + spentOutputs, err := parseSpentOutputs(params.SpentOutputs) + if err != nil { + return NewInvalidParamsResponse(req.ID, "spent outputs") + } + defer func() { + for _, so := range spentOutputs { + so.Destroy() + } + }() + + // Create script pubkey and transaction + scriptPubkey := kernel.NewScriptPubkey(scriptBytes) + defer scriptPubkey.Destroy() + + tx, err := kernel.NewTransaction(txBytes) + if err != nil { + return NewInvalidParamsResponse(req.ID, "transaction decode") + } + defer tx.Destroy() + + // Verify script + valid, err := scriptPubkey.Verify(params.Amount, tx, spentOutputs, params.InputIndex, flags) + if err != nil { + var scriptVerifyError *kernel.ScriptVerifyError + if errors.As(err, &scriptVerifyError) { + switch { + case errors.Is(err, kernel.ErrVerifyScriptVerifyInvalidFlagsCombination): + return NewErrorResponse(req.ID, "btck_ScriptVerifyStatus", "ERROR_INVALID_FLAGS_COMBINATION") + case errors.Is(err, kernel.ErrVerifyScriptVerifySpentOutputsRequired): + return NewErrorResponse(req.ID, "btck_ScriptVerifyStatus", "ERROR_SPENT_OUTPUTS_REQUIRED") + default: + panic("scriptPubkey.Verify returned unhandled ScriptVerifyError (request ID: " + req.ID + "): " + err.Error()) + } + } + panic("scriptPubkey.Verify returned non-ScriptVerifyError (request ID: " + req.ID + "): " + err.Error()) + } + + return Response{ + ID: req.ID, + Result: json.RawMessage(fmt.Sprintf("%t", valid)), + } +} + +// parseScriptFlags parses flags from array or numeric format +func parseScriptFlags(flagsJSON json.RawMessage) (kernel.ScriptFlags, error) { + // Try array format first + var flagsArray []string + if err := json.Unmarshal(flagsJSON, &flagsArray); err == nil { + var result kernel.ScriptFlags + for _, flagStr := range flagsArray { + flag, err := parseSingleFlag(flagStr) + if err != nil { + return 0, err + } + result |= flag + } + return result, nil + } + + // Numeric flags + var numFlags uint32 + if err := json.Unmarshal(flagsJSON, &numFlags); err != nil { + return 0, errors.New("invalid flags format: must be array or number") + } + return kernel.ScriptFlags(numFlags), nil +} + +// parseSingleFlag maps a flag string to its kernel constant +func parseSingleFlag(flagStr string) (kernel.ScriptFlags, error) { + switch flagStr { + case "btck_ScriptVerificationFlags_NONE": + return kernel.ScriptFlagsVerifyNone, nil + case "btck_ScriptVerificationFlags_P2SH": + return kernel.ScriptFlagsVerifyP2SH, nil + case "btck_ScriptVerificationFlags_DERSIG": + return kernel.ScriptFlagsVerifyDERSig, nil + case "btck_ScriptVerificationFlags_NULLDUMMY": + return kernel.ScriptFlagsVerifyNullDummy, nil + case "btck_ScriptVerificationFlags_CHECKLOCKTIMEVERIFY": + return kernel.ScriptFlagsVerifyCheckLockTimeVerify, nil + case "btck_ScriptVerificationFlags_CHECKSEQUENCEVERIFY": + return kernel.ScriptFlagsVerifyCheckSequenceVerify, nil + case "btck_ScriptVerificationFlags_WITNESS": + return kernel.ScriptFlagsVerifyWitness, nil + case "btck_ScriptVerificationFlags_TAPROOT": + return kernel.ScriptFlagsVerifyTaproot, nil + case "btck_ScriptVerificationFlags_ALL": + return kernel.ScriptFlagsVerifyAll, nil + default: + return 0, errors.New("unknown flag: " + flagStr) + } +} + +type SpentOutput struct { + ScriptPubkeyHex string `json:"script_pubkey"` + Amount int64 `json:"amount"` +} + +// parseSpentOutputs parses spent outputs +func parseSpentOutputs(spentOutputParams []SpentOutput) (spentOutputs []*kernel.TransactionOutput, err error) { + defer func() { + // Clean up already created outputs on error + if err != nil { + for _, so := range spentOutputs { + if so != nil { + so.Destroy() + } + } + } + }() + for _, so := range spentOutputParams { + var scriptBytes []byte + scriptBytes, err = hex.DecodeString(so.ScriptPubkeyHex) + if err != nil { + return + } + scriptPubkeyOut := kernel.NewScriptPubkey(scriptBytes) + spentOutputs = append(spentOutputs, kernel.NewTransactionOutput(scriptPubkeyOut, so.Amount)) + } + return spentOutputs, nil +} diff --git a/kernel/errors.go b/kernel/errors.go index d614d6a..dc25786 100644 --- a/kernel/errors.go +++ b/kernel/errors.go @@ -10,7 +10,6 @@ var ( ErrVerifyScriptVerifyInvalidFlagsCombination = &ScriptVerifyError{"Invalid combination of script verification flags"} ErrVerifyScriptVerifySpentOutputsMismatch = &ScriptVerifyError{"Spent outputs count mismatch"} ErrVerifyScriptVerifySpentOutputsRequired = &ScriptVerifyError{"Spent outputs required for verification"} - ErrVerifyScriptVerifyInvalid = &ScriptVerifyError{"Script verification failed"} ) // check panics if ptr is nil, otherwise returns ptr unchanged; used when C calls are not expected to return null @@ -57,6 +56,7 @@ func (e *SerializationError) Error() string { func (e *SerializationError) isKernelError() {} +// ScriptVerifyError represents errors that prevent script verification from executing. type ScriptVerifyError struct { Msg string } diff --git a/kernel/script_pubkey.go b/kernel/script_pubkey.go index f35c119..0e1beae 100644 --- a/kernel/script_pubkey.go +++ b/kernel/script_pubkey.go @@ -85,20 +85,23 @@ func (s *scriptPubkeyApi) Bytes() ([]byte, error) { // - inputIndex: Index of the input in txTo spending the script pubkey. // - flags: ScriptFlags controlling validation constraints. // -// Returns an error if verification fails. -func (s *scriptPubkeyApi) Verify(amount int64, txTo *Transaction, spentOutputs []*TransactionOutput, inputIndex uint, flags ScriptFlags) error { +// Returns: +// - bool: true if the script is valid, false if invalid (only meaningful when error is nil) +// - error: non-nil if verification could not be performed due to malformed input; +// nil if verification completed successfully (check bool for validity result) +func (s *scriptPubkeyApi) Verify(amount int64, txTo *Transaction, spentOutputs []*TransactionOutput, inputIndex uint, flags ScriptFlags) (bool, error) { inputCount := txTo.CountInputs() if inputIndex >= uint(inputCount) { - return ErrVerifyScriptVerifyTxInputIndex + return false, ErrVerifyScriptVerifyTxInputIndex } if len(spentOutputs) > 0 && uint64(len(spentOutputs)) != inputCount { - return ErrVerifyScriptVerifySpentOutputsMismatch + return false, ErrVerifyScriptVerifySpentOutputsMismatch } allFlags := ScriptFlagsVerifyAll if (flags & ^ScriptFlags(allFlags)) != 0 { - return ErrVerifyScriptVerifyInvalidFlags + return false, ErrVerifyScriptVerifyInvalidFlags } var cSpentOutputsPtr **C.btck_TransactionOutput @@ -122,18 +125,20 @@ func (s *scriptPubkeyApi) Verify(amount int64, txTo *Transaction, spentOutputs [ &cStatus, ) - if result != 1 { - status := ScriptVerifyStatus(cStatus) - switch status { - case ScriptVerifyErrorInvalidFlagsCombination: - return ErrVerifyScriptVerifyInvalidFlagsCombination - case ScriptVerifyErrorSpentOutputsRequired: - return ErrVerifyScriptVerifySpentOutputsRequired - default: - return ErrVerifyScriptVerifyInvalid - } + status := ScriptVerifyStatus(cStatus) + + // Check for errors that prevented verification + if status == ScriptVerifyErrorInvalidFlagsCombination { + return false, ErrVerifyScriptVerifyInvalidFlagsCombination } - return nil + if status == ScriptVerifyErrorSpentOutputsRequired { + return false, ErrVerifyScriptVerifySpentOutputsRequired + } + + // Verification completed: result indicates validity + // result == 1: script is valid + // result != 1: script is invalid + return result == 1, nil } // ScriptFlags represents script verification flags that may be composed with each other. diff --git a/kernel/script_pubkey_test.go b/kernel/script_pubkey_test.go index 24ac3aa..993b2d7 100644 --- a/kernel/script_pubkey_test.go +++ b/kernel/script_pubkey_test.go @@ -148,9 +148,11 @@ func TestValidScripts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := testVerifyScript(t, tt.scriptPubkeyHex, tt.amount, tt.txToHex, tt.inputIndex) + valid, err := testVerifyScript(t, tt.scriptPubkeyHex, tt.amount, tt.txToHex, tt.inputIndex) if err != nil { t.Errorf("testVerifyScript() error = %v", err) + } else if !valid { + t.Errorf("testVerifyScript() expected valid script, got invalid") } }) } @@ -203,17 +205,85 @@ func TestInvalidScripts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var scriptVerifyError *ScriptVerifyError - err := testVerifyScript(t, tt.scriptPubkeyHex, tt.amount, tt.txToHex, tt.inputIndex) - if err == nil || !errors.As(err, &scriptVerifyError) { - t.Errorf("testVerifyScript() was expected to fail with ScriptVerifyError, got: %v", err) + valid, err := testVerifyScript(t, tt.scriptPubkeyHex, tt.amount, tt.txToHex, tt.inputIndex) + if err != nil { + t.Errorf("testVerifyScript() unexpected error = %v", err) + } else if valid { + t.Errorf("testVerifyScript() expected invalid script, got valid") + } + }) + } +} + +func TestScriptVerifyErrors(t *testing.T) { + // Use a valid transaction for testing error conditions + validScriptHex := "76a9144bfbaf6afb76cc5771bc6404810d1cc041a6933988ac" + validTxHex := "02000000013f7cebd65c27431a90bba7f796914fe8cc2ddfc3f2cbd6f7e5f2fc854534da95000000006b483045022100de1ac3bcdfb0332207c4a91f3832bd2c2915840165f876ab47c5f8996b971c3602201c6c053d750fadde599e6f5c4e1963df0f01fc0d97815e8157e3d59fe09ca30d012103699b464d1d8bc9e47d4fb1cdaa89a1c5783d68363c4dbc4b524ed3d857148617feffffff02836d3c01000000001976a914fc25d6d5c94003bf5b0c7b640a248e2c637fcfb088ac7ada8202000000001976a914fbed3d9b11183209a57999d54d59f67c019e756c88ac6acb0700" + + scriptBytes, err := hex.DecodeString(validScriptHex) + if err != nil { + t.Fatalf("Failed to decode script hex: %v", err) + } + + scriptPubkey := NewScriptPubkey(scriptBytes) + defer scriptPubkey.Destroy() + + txBytes, err := hex.DecodeString(validTxHex) + if err != nil { + t.Fatalf("Failed to decode transaction hex: %v", err) + } + + tx, err := NewTransaction(txBytes) + if err != nil { + t.Fatalf("Failed to create transaction: %v", err) + } + defer tx.Destroy() + + tests := []struct { + name string + inputIndex uint + flags ScriptFlags + spentOutputs []*TransactionOutput + expectedError error + description string + }{ + { + name: "invalid_input_index", + inputIndex: 999, + flags: ScriptFlagsVerifyAll, + spentOutputs: nil, + expectedError: ErrVerifyScriptVerifyTxInputIndex, + description: "input index out of bounds should return error", + }, + { + name: "invalid_flags", + inputIndex: 0, + flags: ScriptFlags(0xFFFFFFFF), // Invalid flags + spentOutputs: nil, + expectedError: ErrVerifyScriptVerifyInvalidFlags, + description: "invalid flags should return error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid, err := scriptPubkey.Verify(0, tx, tt.spentOutputs, tt.inputIndex, tt.flags) + if err == nil { + t.Errorf("Expected error %v, got nil (valid=%v)", tt.expectedError, valid) + return + } + if !errors.Is(err, tt.expectedError) { + t.Errorf("Expected error %v, got %v", tt.expectedError, err) + } + if valid { + t.Errorf("Expected valid=false when error is returned, got valid=true") } }) } } // testVerifyScript is a helper function that creates the necessary objects and calls VerifyScript -func testVerifyScript(t *testing.T, scriptPubkeyHex string, amount int64, txToHex string, inputIndex uint) error { +func testVerifyScript(t *testing.T, scriptPubkeyHex string, amount int64, txToHex string, inputIndex uint) (bool, error) { scriptPubkeyBytes, err := hex.DecodeString(scriptPubkeyHex) if err != nil { t.Fatalf("Failed to decode script pubkey hex: %v", err)