Skip to content

Commit c9bd7b4

Browse files
committed
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.
1 parent fa8d919 commit c9bd7b4

File tree

2 files changed

+580
-0
lines changed

2 files changed

+580
-0
lines changed

txscript/template.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package txscript
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/hex"
7+
"fmt"
8+
"html/template"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
// ScriptTemplateOpt is a function type for configuring the script template.
14+
type ScriptTemplateOption func(*templateConfig)
15+
16+
// templateConfig holds the configuration for the script template.
17+
type templateConfig struct {
18+
params map[string]interface{}
19+
20+
customFuncs template.FuncMap
21+
}
22+
23+
// WithScriptTemplateParams adds parameters to the script template.
24+
func WithScriptTemplateParams(params map[string]interface{}) ScriptTemplateOption {
25+
return func(cfg *templateConfig) {
26+
for k, v := range params {
27+
cfg.params[k] = v
28+
}
29+
}
30+
}
31+
32+
// WithCustomTemplateFunc adds a custom function to the template.
33+
func WithCustomTemplateFunc(name string, fn interface{}) ScriptTemplateOption {
34+
return func(cfg *templateConfig) {
35+
cfg.customFuncs[name] = fn
36+
}
37+
}
38+
39+
// ScriptTemplate processes a script template with parameters and returns the
40+
// corresponding script bytes. This functions allows Bitcoin scripts to be
41+
// created using a DSL-like syntax, based on Go's templating system.
42+
//
43+
// An example of a simple p2pkh template would be:
44+
//
45+
// `OP_DUP OP_HASH160 0x14e8948c7afa71b6e6fad621256474b5959e0305 OP_EQUALVERIFY OP_CHECKSIG`
46+
//
47+
// Strings that have the `0x` prefix are assumed to byte strings to be pushed
48+
// ontop of the stack. Integers can be passed as normal. If a value can't be
49+
// parsed as an integer, then it's assume that it's a byte slice without the 0x
50+
// prefix.
51+
//
52+
// Normal go template operations can be used as well. The params argument
53+
// houses paramters to pass into the script, for example a local variable
54+
// storing a computed public key.
55+
func ScriptTemplate(scriptTmpl string, opts ...ScriptTemplateOption) ([]byte, error) {
56+
cfg := &templateConfig{
57+
params: make(map[string]interface{}),
58+
customFuncs: make(template.FuncMap),
59+
}
60+
61+
for _, opt := range opts {
62+
opt(cfg)
63+
}
64+
65+
funcMap := template.FuncMap{
66+
"hex": hexEncode,
67+
"hex_str": hexStr,
68+
"unhex": hexDecode,
69+
"range_iter": rangeIter,
70+
}
71+
72+
for k, v := range cfg.customFuncs {
73+
funcMap[k] = v
74+
}
75+
76+
tmpl, err := template.New("script").Funcs(funcMap).Parse(scriptTmpl)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to parse template: %w", err)
79+
}
80+
81+
var buf bytes.Buffer
82+
if err := tmpl.Execute(&buf, cfg.params); err != nil {
83+
return nil, fmt.Errorf("failed to execute template: %w", err)
84+
}
85+
86+
return processScript(buf.String())
87+
}
88+
89+
// looksLikeInt checks if a string looks like an integer.
90+
func looksLikeInt(s string) bool {
91+
// Check if the string starts with an optional sign.
92+
if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
93+
s = s[1:]
94+
}
95+
96+
// Check if the remaining string contains only digits.
97+
for _, c := range s {
98+
if c < '0' || c > '9' {
99+
return false
100+
}
101+
}
102+
103+
return len(s) > 0
104+
}
105+
106+
107+
// processScript converts the template output to actual script bytes. We scan
108+
// each line, then go through each element one by one, deciding to either add a
109+
// normal op code, a push data, or an integer value.
110+
func processScript(script string) ([]byte, error) {
111+
var builder ScriptBuilder
112+
113+
// We'll a bufio scanner to take care of some of the parsing for us.
114+
// bufio.ScanWords will split on word boundaries, based on unicode
115+
// characters.
116+
scanner := bufio.NewScanner(strings.NewReader(script))
117+
scanner.Split(bufio.ScanWords)
118+
119+
// Run through each word, deciding if we should add an op code, a push
120+
// data, or an integer value.
121+
for scanner.Scan() {
122+
token := scanner.Text()
123+
switch {
124+
// If it starts with OP_, then we'll try to parse out the op
125+
// code.
126+
case strings.HasPrefix(token, "OP_"):
127+
opcode, ok := OpcodeByName[token]
128+
if !ok {
129+
return nil, fmt.Errorf("unknown opcode: "+
130+
"%s", token)
131+
}
132+
133+
builder.AddOp(opcode)
134+
135+
// If it has an 0x prefix, then we'll try to decode it as a hex
136+
// string to push data.
137+
case strings.HasPrefix(token, "0x"):
138+
data, err := hex.DecodeString(
139+
strings.TrimPrefix(token, "0x"),
140+
)
141+
if err != nil {
142+
return nil, fmt.Errorf("invalid hex "+
143+
"data: %s", token)
144+
}
145+
146+
builder.AddData(data)
147+
148+
// Next, we'll try to parse ints for the integer op code.
149+
case looksLikeInt(token):
150+
val, err := strconv.ParseInt(token, 10, 64)
151+
if err != nil {
152+
return nil, fmt.Errorf("invalid "+
153+
"integer: %s", token)
154+
}
155+
156+
builder.AddInt64(val)
157+
158+
// Otherwise, we assume it's a byte string without the 0x
159+
// prefix.
160+
default:
161+
data, err := hex.DecodeString(token)
162+
if err != nil {
163+
return nil, fmt.Errorf("invalid token: %s",
164+
token)
165+
}
166+
167+
builder.AddData(data)
168+
}
169+
}
170+
171+
if err := scanner.Err(); err != nil {
172+
return nil, fmt.Errorf("error reading script: %w", err)
173+
}
174+
175+
return builder.Script()
176+
}
177+
178+
// rangeIter is useful for being able to execute a bounded for loop.
179+
func rangeIter(start, end int) []int {
180+
var result []int
181+
182+
for i := start; i < end; i++ {
183+
result = append(result, i)
184+
}
185+
186+
return result
187+
}
188+
189+
// hexEncode is a helper function to encode bytes to hex in templates.
190+
// It adds the "0x" prefix to ensure the output is processed as hex data
191+
// and not misinterpreted as an integer.
192+
func hexEncode(data []byte) string {
193+
return "0x" + hex.EncodeToString(data)
194+
}
195+
196+
// hexStr is a helper function to encode bytes to a raw hex string in templates
197+
// without the "0x" prefix.
198+
func hexStr(data []byte) string {
199+
return hex.EncodeToString(data)
200+
}
201+
202+
// hexDecode is a helper function to decode hex to bytes in templates
203+
func hexDecode(s string) ([]byte, error) {
204+
return hex.DecodeString(strings.TrimPrefix(s, "0x"))
205+
}
206+
207+
// Example usage:
208+
func ExampleScriptTemplate() {
209+
localPubkey, _ := hex.DecodeString("14e8948c7afa71b6e6fad621256474b5959e0305")
210+
211+
scriptBytes, err := ScriptTemplate(`
212+
OP_DUP OP_HASH160 0x14e8948c7afa71b6e6fad621256474b5959e0305 OP_EQUALVERIFY OP_CHECKSIG
213+
OP_DUP OP_HASH160 {{ hex .LocalPubkeyHash }} OP_EQUALVERIFY OP_CHECKSIG
214+
{{ .Timeout }} OP_CHECKLOCKTIMEVERIFY OP_DROP
215+
216+
{{- range $i := range_iter 0 3 }}
217+
{{ add 10 $i }} OP_ADD
218+
{{- end }}`,
219+
WithScriptTemplateParams(map[string]interface{}{
220+
"LocalPubkeyHash": localPubkey,
221+
"Timeout": 1,
222+
}),
223+
)
224+
if err != nil {
225+
fmt.Printf("Error: %v\n", err)
226+
return
227+
}
228+
229+
asmScript, err := DisasmString(scriptBytes)
230+
if err != nil {
231+
fmt.Printf("Error converting to ASM: %v\n", err)
232+
return
233+
}
234+
235+
fmt.Printf("Script ASM:\n%s\n", asmScript)
236+
}

0 commit comments

Comments
 (0)