Skip to content

Commit

Permalink
txscript: add new ScriptTemplate DSL for writing Scripts
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Roasbeef committed Jul 18, 2024
1 parent ff2e03e commit 9335b4b
Showing 1 changed file with 226 additions and 0 deletions.
226 changes: 226 additions & 0 deletions txscript/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
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,
"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
func hexEncode(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)
}

0 comments on commit 9335b4b

Please sign in to comment.