diff --git a/txscript/template.go b/txscript/template.go new file mode 100644 index 00000000..cc30796d --- /dev/null +++ b/txscript/template.go @@ -0,0 +1,236 @@ +package txscript + +import ( + "bufio" + "bytes" + "encoding/hex" + "fmt" + "html/template" + "strconv" + "strings" +) + +// ScriptTemplateOpt is a function type for configuring the script template. +type ScriptTemplateOption func(*templateConfig) + +// templateConfig holds the configuration for the script template. +type templateConfig struct { + params map[string]interface{} + + customFuncs template.FuncMap +} + +// WithScriptTemplateParams adds parameters to the script template. +func WithScriptTemplateParams(params map[string]interface{}) ScriptTemplateOption { + return func(cfg *templateConfig) { + for k, v := range params { + cfg.params[k] = v + } + } +} + +// WithCustomTemplateFunc adds a custom function to the template. +func WithCustomTemplateFunc(name string, fn interface{}) ScriptTemplateOption { + return func(cfg *templateConfig) { + cfg.customFuncs[name] = fn + } +} + +// ScriptTemplate processes a script template with parameters and returns the +// corresponding script bytes. This functions allows Bitcoin scripts to be +// created using a DSL-like syntax, based on Go's templating system. +// +// An example of a simple p2pkh template would be: +// +// `OP_DUP OP_HASH160 0x14e8948c7afa71b6e6fad621256474b5959e0305 OP_EQUALVERIFY OP_CHECKSIG` +// +// Strings that have the `0x` prefix are assumed to byte strings to be pushed +// ontop of the stack. Integers can be passed as normal. If a value can't be +// parsed as an integer, then it's assume that it's a byte slice without the 0x +// prefix. +// +// Normal go template operations can be used as well. The params argument +// houses paramters to pass into the script, for example a local variable +// storing a computed public key. +func ScriptTemplate(scriptTmpl string, opts ...ScriptTemplateOption) ([]byte, error) { + cfg := &templateConfig{ + params: make(map[string]interface{}), + customFuncs: make(template.FuncMap), + } + + for _, opt := range opts { + opt(cfg) + } + + funcMap := template.FuncMap{ + "hex": hexEncode, + "hex_str": hexStr, + "unhex": hexDecode, + "range_iter": rangeIter, + } + + for k, v := range cfg.customFuncs { + funcMap[k] = v + } + + tmpl, err := template.New("script").Funcs(funcMap).Parse(scriptTmpl) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, cfg.params); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return processScript(buf.String()) +} + +// looksLikeInt checks if a string looks like an integer. +func looksLikeInt(s string) bool { + // Check if the string starts with an optional sign. + if len(s) > 0 && (s[0] == '+' || s[0] == '-') { + s = s[1:] + } + + // Check if the remaining string contains only digits. + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + + return len(s) > 0 +} + + +// processScript converts the template output to actual script bytes. We scan +// each line, then go through each element one by one, deciding to either add a +// normal op code, a push data, or an integer value. +func processScript(script string) ([]byte, error) { + var builder ScriptBuilder + + // We'll a bufio scanner to take care of some of the parsing for us. + // bufio.ScanWords will split on word boundaries, based on unicode + // characters. + scanner := bufio.NewScanner(strings.NewReader(script)) + scanner.Split(bufio.ScanWords) + + // Run through each word, deciding if we should add an op code, a push + // data, or an integer value. + for scanner.Scan() { + token := scanner.Text() + switch { + // If it starts with OP_, then we'll try to parse out the op + // code. + case strings.HasPrefix(token, "OP_"): + opcode, ok := OpcodeByName[token] + if !ok { + return nil, fmt.Errorf("unknown opcode: "+ + "%s", token) + } + + builder.AddOp(opcode) + + // If it has an 0x prefix, then we'll try to decode it as a hex + // string to push data. + case strings.HasPrefix(token, "0x"): + data, err := hex.DecodeString( + strings.TrimPrefix(token, "0x"), + ) + if err != nil { + return nil, fmt.Errorf("invalid hex "+ + "data: %s", token) + } + + builder.AddData(data) + + // Next, we'll try to parse ints for the integer op code. + case looksLikeInt(token): + val, err := strconv.ParseInt(token, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid "+ + "integer: %s", token) + } + + builder.AddInt64(val) + + // Otherwise, we assume it's a byte string without the 0x + // prefix. + default: + data, err := hex.DecodeString(token) + if err != nil { + return nil, fmt.Errorf("invalid token: %s", + token) + } + + builder.AddData(data) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading script: %w", err) + } + + return builder.Script() +} + +// rangeIter is useful for being able to execute a bounded for loop. +func rangeIter(start, end int) []int { + var result []int + + for i := start; i < end; i++ { + result = append(result, i) + } + + return result +} + +// hexEncode is a helper function to encode bytes to hex in templates. +// It adds the "0x" prefix to ensure the output is processed as hex data +// and not misinterpreted as an integer. +func hexEncode(data []byte) string { + return "0x" + hex.EncodeToString(data) +} + +// hexStr is a helper function to encode bytes to a raw hex string in templates +// without the "0x" prefix. +func hexStr(data []byte) string { + return hex.EncodeToString(data) +} + +// hexDecode is a helper function to decode hex to bytes in templates +func hexDecode(s string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(s, "0x")) +} + +// Example usage: +func ExampleScriptTemplate() { + localPubkey, _ := hex.DecodeString("14e8948c7afa71b6e6fad621256474b5959e0305") + + scriptBytes, err := ScriptTemplate(` + OP_DUP OP_HASH160 0x14e8948c7afa71b6e6fad621256474b5959e0305 OP_EQUALVERIFY OP_CHECKSIG + OP_DUP OP_HASH160 {{ hex .LocalPubkeyHash }} OP_EQUALVERIFY OP_CHECKSIG + {{ .Timeout }} OP_CHECKLOCKTIMEVERIFY OP_DROP + + {{- range $i := range_iter 0 3 }} + {{ add 10 $i }} OP_ADD + {{- end }}`, + WithScriptTemplateParams(map[string]interface{}{ + "LocalPubkeyHash": localPubkey, + "Timeout": 1, + }), + ) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + asmScript, err := DisasmString(scriptBytes) + if err != nil { + fmt.Printf("Error converting to ASM: %v\n", err) + return + } + + fmt.Printf("Script ASM:\n%s\n", asmScript) +} diff --git a/txscript/template_test.go b/txscript/template_test.go new file mode 100644 index 00000000..2d276047 --- /dev/null +++ b/txscript/template_test.go @@ -0,0 +1,344 @@ +package txscript + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestScriptTemplateLooksLikeInt tests the looksLikeInt function. +func TestScriptTemplateLooksLikeInt(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"123", true}, + {"-123", true}, + {"+123", true}, + {"0", true}, + {"+0", true}, + {"-0", true}, + {"abc", false}, + {"12a", false}, + {"", false}, + {"+", false}, + {"-", false}, + {"++123", false}, + {"--123", false}, + {"+-123", false}, + {"1.23", false}, + } + + for _, test := range tests { + result := looksLikeInt(test.input) + require.Equal( + t, test.expected, result, + "looksLikeInt(%q) = %v, want %v", + test.input, result, test.expected, + ) + } +} + +// TestScriptTemplate tests the ScriptTemplate function. +func TestScriptTemplate(t *testing.T) { + tests := []struct { + name string + template string + params map[string]interface{} + customFunc map[string]interface{} + expected string + wantErr bool + }{ + { + name: "simple P2PKH", + template: "OP_DUP OP_HASH160 " + + "0x14e8948c7afa71b6e6fad621256474b5959e0305 " + + "OP_EQUALVERIFY OP_CHECKSIG", + params: nil, + expected: "OP_DUP OP_HASH160 " + + "14e8948c7afa71b6e6fad621256474b5959e0305 " + + "OP_EQUALVERIFY OP_CHECKSIG", + wantErr: false, + }, + { + name: "with positive integer", + template: "123 OP_ADD", + params: nil, + expected: "7b OP_ADD", + wantErr: false, + }, + { + name: "with negative integer", + template: "-42 OP_ADD", + params: nil, + expected: "aa OP_ADD", + wantErr: false, + }, + { + name: "with zero bytes for OP_CHECKSIG", + template: "0x0000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIG", + params: nil, + expected: "0000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIG", + wantErr: false, + }, + { + name: "with hex template function for zero bytes", + template: "{{ hex .ZeroSig }} OP_CHECKSIG", + params: map[string]interface{}{ + "ZeroSig": make([]byte, 32), + }, + expected: "0000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIG", + wantErr: false, + }, + { + name: "with hex data without 0x prefix", + template: "abcdef OP_ADD", + params: nil, + expected: "abcdef OP_ADD", + wantErr: false, + }, + { + name: "with template parameter", + template: "OP_DUP OP_HASH160 {{ hex .Pubkey }} " + + "OP_EQUALVERIFY OP_CHECKSIG", + params: map[string]interface{}{ + "Pubkey": []byte{ + 0x14, 0xe8, 0x94, 0x8c, 0x7a, 0xfa, + 0x71, 0xb6, 0xe6, 0xfa, 0xd6, 0x21, + 0x25, 0x64, 0x74, 0xb5, 0x95, 0x9e, + 0x03, 0x05, + }, + }, + expected: "OP_DUP OP_HASH160 " + + "14e8948c7afa71b6e6fad621256474b5959e0305 " + + "OP_EQUALVERIFY OP_CHECKSIG", + wantErr: false, + }, + { + name: "with range iteration", + template: ` + {{ range $i := range_iter 1 4 }} + OP_DUP OP_HASH160 + {{ if eq $i 1 }} + 0x01 + {{ else if eq $i 2 }} + 0x02 + {{ else }} + 0x03 + {{ end }} + OP_EQUALVERIFY {{ end }} + OP_CHECKSIG`, + params: nil, + expected: "OP_DUP OP_HASH160 1 OP_EQUALVERIFY " + + "OP_DUP OP_HASH160 2 OP_EQUALVERIFY " + + "OP_DUP OP_HASH160 3 OP_EQUALVERIFY " + + "OP_CHECKSIG", + wantErr: false, + }, + { + name: "with custom function", + template: "{{ add 10 5 }} OP_DROP", + params: nil, + customFunc: map[string]interface{}{ + "add": func(a, b int) int { + return a + b + }, + }, + expected: "15 OP_DROP", + wantErr: false, + }, + { + name: "invalid opcode", + template: "OP_UNKNOWN", + params: nil, + wantErr: true, + }, + { + name: "invalid hex", + template: "0xZZ", + params: nil, + wantErr: true, + }, + { + name: "invalid integer", + template: "9999999999999999999999999999", + params: nil, + wantErr: true, + }, + { + name: "invalid token", + template: "not_hex_or_op", + params: nil, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var opts []ScriptTemplateOption + if test.params != nil { + opts = append( + opts, + WithScriptTemplateParams(test.params), + ) + } + + // Add custom functions if specified. + for name, fn := range test.customFunc { + opts = append( + opts, WithCustomTemplateFunc(name, fn), + ) + } + + script, err := ScriptTemplate(test.template, opts...) + + if test.wantErr { + require.Error( + t, err, + "ScriptTemplate(%q) expected error, got nil", + test.template, + ) + + return + } + + require.NoError( + t, err, + "ScriptTemplate(%q) unexpected error", + test.template, + ) + + // Disassemble the script and compare the string + // representation. + disasm, err := DisasmString(script) + require.NoError(t, err, "Failed to disassemble script") + + require.Equal( + t, test.expected, disasm, + "ScriptTemplate(%q):\ngot: %s\nwant: %s", + test.template, disasm, test.expected, + ) + }) + } +} + +// TestScriptTemplateOptions tests the ScriptTemplate option functions. +func TestScriptTemplateOptions(t *testing.T) { + t.Run("WithScriptTemplateParams", func(t *testing.T) { + template := "{{ .Value }} OP_DROP" + params := map[string]interface{}{ + "Value": 42, + } + + script, err := ScriptTemplate( + template, WithScriptTemplateParams(params), + ) + require.NoError(t, err, "unexpected error") + + disasm, err := DisasmString(script) + require.NoError(t, err, "Failed to disassemble script") + + expected := "2a OP_DROP" + require.Equal( + t, expected, disasm, + "ScriptTemplate(%q):\ngot: %s\nwant: %s", + template, disasm, expected, + ) + }) + + t.Run("WithCustomTemplateFunc", func(t *testing.T) { + template := "{{ multiply 6 7 }} OP_DROP" + script, err := ScriptTemplate( + template, + WithCustomTemplateFunc("multiply", func(a, b int) int { + return a * b + }), + ) + require.NoError(t, err, "Unexpected error") + + disasm, err := DisasmString(script) + require.NoError(t, err, "Failed to disassemble script") + + expected := "2a OP_DROP" + require.Equal( + t, expected, disasm, + "ScriptTemplate(%q):\ngot: %s\nwant: %s", + template, disasm, expected, + ) + }) +} + +// TestScriptTemplateHelperFunctions tests the helper functions used in +// templates. +func TestScriptTemplateHelperFunctions(t *testing.T) { + t.Run("rangeIter", func(t *testing.T) { + result := rangeIter(2, 5) + expected := []int{2, 3, 4} + + require.Equal( + t, expected, result, + "rangeIter(2, 5) returned unexpected result", + ) + }) + + t.Run("hexEncode", func(t *testing.T) { + input := []byte{0x12, 0x34, 0x56} + result := hexEncode(input) + expected := "0x123456" + + require.Equal( + t, expected, result, + "hexEncode(%v) = %q, want %q", + input, result, expected, + ) + }) + + t.Run("hexStr", func(t *testing.T) { + input := []byte{0x12, 0x34, 0x56} + result := hexStr(input) + expected := "123456" + + require.Equal( + t, expected, result, + "hexStr(%v) = %q, want %q", + input, result, expected, + ) + }) + + t.Run("hexDecode", func(t *testing.T) { + tests := []struct { + input string + expected []byte + wantErr bool + }{ + {"123456", []byte{0x12, 0x34, 0x56}, false}, + {"0x123456", []byte{0x12, 0x34, 0x56}, false}, + {"zz", nil, true}, + } + + for _, test := range tests { + result, err := hexDecode(test.input) + + if test.wantErr { + require.Error( + t, err, + "hexDecode(%q) expected error, got nil", + test.input, + ) + continue + } + + require.NoError( + t, err, + "hexDecode(%q) unexpected error", + test.input, + ) + + require.Equal( + t, test.expected, result, + "hexDecode(%q) = %v, want %v", + test.input, result, test.expected, + ) + } + }) +}