mirror of
https://github.com/btcsuite/btcd.git
synced 2026-08-13 12:32:51 +02:00
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:
parent
fa8d919dd0
commit
c9bd7b4c0f
2 changed files with 580 additions and 0 deletions
236
txscript/template.go
Normal file
236
txscript/template.go
Normal 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
344
txscript/template_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue