txscript: add new ScriptTemplate DSL for writing Scripts

In this commit, we add a new function, `ScriptTemplate` to make the
process of making custom Bitcoin scripts a bit less verbose.

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.
This commit is contained in:
Olaoluwa Osuntokun 2024-07-17 18:44:55 -07:00
parent fa8d919dd0
commit c9bd7b4c0f
No known key found for this signature in database
GPG key ID: 90525F7DEEE0AD86
2 changed files with 580 additions and 0 deletions

236
txscript/template.go Normal file
View file

@ -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)
}

344
txscript/template_test.go Normal file
View file

@ -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,
)
}
})
}