From 68a0a2627952ef879a565c148f6202972c58afbe Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 11 Nov 2026 11:47:01 +0100 Subject: [PATCH 1/5] feat(template): evaluate bounded input predicates --- pkg/template/build.go | 3 + pkg/template/fixtures_test.go | 2 +- pkg/template/input.go | 8 + pkg/template/internal/schema/schema.go | 11 +- pkg/template/predicate.go | 156 ++++++++ pkg/template/predicate_test.go | 351 ++++++++++++++++++ pkg/template/testdata/ids.json | 1 + ...fline_receive_amount_predicate_vector.json | 13 + .../testdata/offline_receive_predicate.json | 94 +++++ .../offline_receive_predicate_vector.json | 13 + 10 files changed, 646 insertions(+), 6 deletions(-) create mode 100644 pkg/template/predicate.go create mode 100644 pkg/template/predicate_test.go create mode 100644 pkg/template/testdata/offline_receive_amount_predicate_vector.json create mode 100644 pkg/template/testdata/offline_receive_predicate.json create mode 100644 pkg/template/testdata/offline_receive_predicate_vector.json diff --git a/pkg/template/build.go b/pkg/template/build.go index a6d03d0..d88a03c 100644 --- a/pkg/template/build.go +++ b/pkg/template/build.go @@ -56,6 +56,9 @@ func (i *Instance) Build(sources []*Source, fee Fee) (*Action, error) { if err != nil { return nil, err } + if err := i.predicates(d); err != nil { + return nil, err + } charged := uint64(0) if t.typ == Intent { diff --git a/pkg/template/fixtures_test.go b/pkg/template/fixtures_test.go index bc414ce..801ddee 100644 --- a/pkg/template/fixtures_test.go +++ b/pkg/template/fixtures_test.go @@ -34,7 +34,7 @@ func TestFixtures(t *testing.T) { var ids map[string]string require.NoError(t, json.Unmarshal(fixture(t, "ids.json"), &ids)) docs := artifacts(t) - for _, name := range []string{"renew.json", "board.json", "counter.json", "vhtlc_claim.json", "vhtlc_refund.json", "claim.json", "offline_receive.json", "onchain_release.json", "counter_seed.json", "minimal.json"} { + for _, name := range []string{"renew.json", "board.json", "counter.json", "vhtlc_claim.json", "vhtlc_refund.json", "claim.json", "offline_receive.json", "offline_receive_predicate.json", "onchain_release.json", "counter_seed.json", "minimal.json"} { t.Run(name, func(t *testing.T) { tmpl, err := Parse(fixture(t, name), docs) require.NoError(t, err) diff --git a/pkg/template/input.go b/pkg/template/input.go index 2c60192..814b27c 100644 --- a/pkg/template/input.go +++ b/pkg/template/input.go @@ -31,6 +31,7 @@ type input struct { function, leaf string arguments []byteTemplate // covenant inputs, ABI order witness map[string]byteTemplate // non-signature witness items + predicate *byteTemplate } func (c *compiler) input(s *schema.Input) error { @@ -55,6 +56,13 @@ func (c *compiler) input(s *schema.Input) error { if err := c.spend(&in, &s.Spend); err != nil { return err } + if s.Predicate != nil { + b, err := c.bind(*s.Predicate, "bytes", true) + if err != nil { + return err + } + in.predicate = &b + } c.t.inputs = append(c.t.inputs, in) return nil } diff --git a/pkg/template/internal/schema/schema.go b/pkg/template/internal/schema/schema.go index 2f7239f..c34e9c3 100644 --- a/pkg/template/internal/schema/schema.go +++ b/pkg/template/internal/schema/schema.go @@ -52,11 +52,12 @@ type Secret struct { } type Input struct { - Name string `json:"name"` // identifier - Type string `json:"type,omitempty" schema:"opt"` // offchain or onchain; defaults to the template's kind - Schedule *Schedule `json:"schedule,omitempty" schema:"opt"` // defaults to immediate - Contract Construction `json:"contract"` - Spend Spend `json:"spend"` + Name string `json:"name"` // identifier + Type string `json:"type,omitempty" schema:"opt"` // offchain or onchain; defaults to the template's kind + Schedule *Schedule `json:"schedule,omitempty" schema:"opt"` // defaults to immediate + Contract Construction `json:"contract"` + Spend Spend `json:"spend"` + Predicate *Binding `json:"predicate,omitempty" schema:"opt"` } // Schedule is when an input becomes eligible; exactly one field is set. diff --git a/pkg/template/predicate.go b/pkg/template/predicate.go new file mode 100644 index 0000000..e11406c --- /dev/null +++ b/pkg/template/predicate.go @@ -0,0 +1,156 @@ +package template + +import ( + "bytes" + "fmt" + + "github.com/arkade-os/emulator/pkg/arkade" + "github.com/btcsuite/btcd/txscript/v2" +) + +func (i *Instance) predicates(d *draft) error { + programs := make([][]byte, len(i.tmpl.inputs)) + total := map[byte]int{} + for slot, in := range i.tmpl.inputs { + if in.predicate == nil { + continue + } + program, err := i.resolve(*in.predicate, d, d.index(slot)) + if err != nil { + return fmt.Errorf("%w: input %q predicate binding failed", ErrIneligible, in.name) + } + if err := checkPredicate(program, total); err != nil { + return err + } + programs[slot] = program + } + for slot, program := range programs { + if program == nil { + continue + } + index := d.index(slot) + vm, err := arkade.NewEngine(program, d.tx, index, nil, nil, int64(d.sources[index].Amount), d) + if err != nil { + return predicateError(err) + } + if expiry := d.sources[index].Expiry; !expiry.IsZero() { + arkade.WithExpiry(expiry.Unix())(vm) + } + for steps, done := 0, false; !done; steps++ { + if steps >= len(program) { + return fmt.Errorf("%w: predicate step limit", ErrIneligible) + } + if done, err = vm.Step(); err != nil { + return predicateError(err) + } + } + stack := vm.GetStack() + if len(stack) != 1 || !bytes.Equal(stack[0], []byte{1}) { + return fmt.Errorf("%w: predicate must return one canonical true", ErrIneligible) + } + } + return nil +} + +func predicateError(err error) error { + return fmt.Errorf("%w: predicate: %s", ErrIneligible, vmError(err)) +} + +func checkPredicate(program []byte, total map[byte]int) error { + if len(program) < 1 || len(program) > 520 { + return fmt.Errorf("%w: predicate size outside 1..520", ErrIneligible) + } + counts := map[byte]int{} + // Raw engines have no taproot context: the pinned VM's compute budget does not charge them. + // Loopless programs permit conservative counting of every branch before any predicate runs. + ops := 0 + tokens := txscript.MakeScriptTokenizer(0, program) + for tokens.Next() { + op := tokens.Opcode() + if op <= txscript.OP_PUSHDATA4 { + if !minimalPredicatePush(op, tokens.Data()) { + return fmt.Errorf("%w: predicate nonminimal push", ErrIneligible) + } + continue + } + if !predicateOpcode(op) { + return fmt.Errorf("%w: predicate forbidden opcode", ErrIneligible) + } + if op > txscript.OP_16 { + ops++ + } + if ops > 201 { + return fmt.Errorf("%w: predicate opcode limit", ErrIneligible) + } + counts[op]++ + total[op]++ + limit := predicateComputeLimit(op) + if limit > 0 && counts[op] > limit { + return fmt.Errorf("%w: predicate compute limit", ErrIneligible) + } + if limit > 0 && total[op] > 4*limit { + return fmt.Errorf("%w: predicate aggregate compute limit", ErrIneligible) + } + } + if err := tokens.Err(); err != nil { + return predicateError(err) + } + return nil +} + +func predicateComputeLimit(op byte) int { + switch op { + case arkade.OP_CHECKSIGFROMSTACK, arkade.OP_ECMUL, arkade.OP_ECMULSCALARVERIFY, arkade.OP_TWEAKVERIFY: + return 50 + case arkade.OP_ECADD: + return 1000 + case arkade.OP_ECPAIRING: + return 2 + case arkade.OP_MODEXP: + return 64 + } + return 0 +} + +func minimalPredicatePush(op byte, data []byte) bool { + switch n := len(data); { + case n == 0: + return op == txscript.OP_0 + case n == 1 && data[0] >= 1 && data[0] <= 16: + return false + case n == 1 && data[0] == 0x81: + return false + case n <= 75: + return int(op) == n + case n <= 255: + return op == txscript.OP_PUSHDATA1 + default: + return op == txscript.OP_PUSHDATA2 + } +} + +func predicateOpcode(op byte) bool { + if op == txscript.OP_1NEGATE || op >= txscript.OP_1 && op <= txscript.OP_16 { + return true + } + switch op { + case arkade.OP_NOP, arkade.OP_IF, arkade.OP_NOTIF, arkade.OP_ELSE, arkade.OP_ENDIF, arkade.OP_VERIFY, arkade.OP_RETURN, + arkade.OP_TOALTSTACK, arkade.OP_FROMALTSTACK, arkade.OP_2DROP, arkade.OP_2DUP, arkade.OP_3DUP, arkade.OP_2OVER, + arkade.OP_2ROT, arkade.OP_2SWAP, arkade.OP_IFDUP, arkade.OP_DEPTH, arkade.OP_DROP, arkade.OP_DUP, arkade.OP_NIP, + arkade.OP_OVER, arkade.OP_PICK, arkade.OP_ROLL, arkade.OP_ROT, arkade.OP_SWAP, arkade.OP_TUCK, + arkade.OP_CAT, arkade.OP_SUBSTR, arkade.OP_LEFT, arkade.OP_RIGHT, arkade.OP_SIZE, + arkade.OP_INVERT, arkade.OP_AND, arkade.OP_OR, arkade.OP_XOR, arkade.OP_EQUAL, arkade.OP_EQUALVERIFY, + arkade.OP_1ADD, arkade.OP_1SUB, arkade.OP_2MUL, arkade.OP_2DIV, arkade.OP_NEGATE, arkade.OP_ABS, arkade.OP_NOT, + arkade.OP_0NOTEQUAL, arkade.OP_ADD, arkade.OP_SUB, arkade.OP_MUL, arkade.OP_DIV, arkade.OP_MOD, + arkade.OP_LSHIFT, arkade.OP_RSHIFT, arkade.OP_BOOLAND, arkade.OP_BOOLOR, arkade.OP_NUMEQUAL, arkade.OP_NUMEQUALVERIFY, + arkade.OP_NUMNOTEQUAL, arkade.OP_LESSTHAN, arkade.OP_GREATERTHAN, arkade.OP_LESSTHANOREQUAL, arkade.OP_GREATERTHANOREQUAL, + arkade.OP_MIN, arkade.OP_MAX, arkade.OP_WITHIN, arkade.OP_RIPEMD160, arkade.OP_SHA1, arkade.OP_SHA256, arkade.OP_HASH160, arkade.OP_HASH256, + arkade.OP_CHECKLOCKTIMEVERIFY, arkade.OP_CHECKSEQUENCEVERIFY, arkade.OP_CHECKSIGFROMSTACK, + arkade.OP_INSPECTINPUTOUTPOINT, arkade.OP_INSPECTINPUTVALUE, arkade.OP_INSPECTINPUTSCRIPTPUBKEY, arkade.OP_INSPECTINPUTSEQUENCE, + arkade.OP_PUSHCURRENTINPUTINDEX, arkade.OP_INSPECTVERSION, arkade.OP_INSPECTLOCKTIME, arkade.OP_INSPECTNUMINPUTS, + arkade.OP_NUM2BIN, arkade.OP_BIN2NUM, arkade.OP_REVERSEBYTES, arkade.OP_MODEXP, arkade.OP_PUSHEXPIRY, arkade.OP_CHECKTIME, + arkade.OP_ECADD, arkade.OP_ECMUL, arkade.OP_ECPAIRING, arkade.OP_ECMULSCALARVERIFY, arkade.OP_TWEAKVERIFY, arkade.OP_INSPECTINPUTPACKET: + return true + } + return false +} diff --git a/pkg/template/predicate_test.go b/pkg/template/predicate_test.go new file mode 100644 index 0000000..a0e621b --- /dev/null +++ b/pkg/template/predicate_test.go @@ -0,0 +1,351 @@ +package template + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + + "github.com/arkade-os/arkd/pkg/ark-lib/txutils" + "github.com/arkade-os/emulator/pkg/arkade" + "github.com/arkade-os/executor/pkg/template/ecies" + "github.com/arkade-os/executor/pkg/template/internal/asm" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/stretchr/testify/require" +) + +func predicateBuild(t *testing.T, typ string, programs ...string) (*Instance, []*Source) { + t.Helper() + base := parseFixture(t, "minimal.json") + seed, err := base.Instantiate(Context{Keys: testKeys(t)}) + require.NoError(t, err) + doc := edited(t, fixture(t, "minimal.json"), func(m map[string]any) { + m["type"] = typ + delete(m, "fees") + in := m["inputs"].([]any)[0].(map[string]any) + out := m["outputs"].([]any)[0].(map[string]any) + inputs, outputs := []any{}, []any{} + for slot, program := range programs { + name := fmt.Sprintf("funds%d", slot) + inputs = append(inputs, map[string]any{"name": name, "contract": in["contract"], "spend": in["spend"], "predicate": program}) + outputs = append(outputs, map[string]any{"name": fmt.Sprintf("payment%d", slot), "index": slot, "value": map[string]any{"from": name}, "locking": out["locking"]}) + } + m["inputs"], m["outputs"] = inputs, outputs + }) + tmpl, err := Parse(doc, nil) + require.NoError(t, err) + require.True(t, tmpl.ContextFree()) + sources := make([]*Source, len(programs)) + for slot := range sources { + sources[slot] = sourceWith(t, int64(10000+slot)) + lockTo(sources[slot], seed.PkScript(0)) + } + inst, err := tmpl.Instantiate(Context{Keys: testKeys(t), Sources: sources}) + require.NoError(t, err) + return inst, sources +} + +func TestOfflineReceivePredicateSDKVectors(t *testing.T) { + v := vhtlcVector(t) + tree, err := txutils.TapTree(v.Tapscripts).Encode() + require.NoError(t, err) + unhex := func(s string) []byte { b, err := hex.DecodeString(s); require.NoError(t, err); return b } + cov, err := asm.Assemble(nonInteractiveClaimCovenant, func(string) (asm.Value, error) { return asm.Value{Type: "bytes32", Raw: unhex(v.ReceiverProgram)}, nil }) + require.NoError(t, err) + tmpl := parseFixture(t, "offline_receive_predicate.json") + for _, name := range []string{"offline_receive_predicate_vector.json", "offline_receive_amount_predicate_vector.json"} { + t.Run(name, func(t *testing.T) { + var vector struct{ TemplateID, Packet, Predicate, RecipientSecretKey string } + require.NoError(t, json.Unmarshal(fixture(t, name), &vector)) + require.Equal(t, tmpl.ID(), vector.TemplateID) + key, _ := btcec.PrivKeyFromBytes(unhex(vector.RecipientSecretKey)) + body, err := base64.StdEncoding.DecodeString(vector.Packet) + require.NoError(t, err) + for _, amount := range []int64{9999, 10000, 12000} { + ptx, _ := streamed(t, append(bytes.Clone(body), tlv(2, cov)...)) + ptx.UnsignedTx.TxOut[0].PkScript = unhex(v.PkScript) + ptx.UnsignedTx.TxOut[0].Value = amount + ptx.Outputs[0].TaprootTapTree = tree + got, err := tmpl.Trigger().Match(ptx, TriggerKeys{Encryption: key.PubKey()}) + require.NoError(t, err) + vars := map[string][]byte{} + for n, value := range got[0].Variables { + vars[n] = unhex(value) + } + inst, err := tmpl.Instantiate(Context{Keys: testKeys(t), Variables: vars, Decrypt: func(ct []byte) ([]byte, error) { return ecies.Decrypt(key, ct, tmpl.ID()) }}) + require.NoError(t, err) + src := &Source{Tx: ptx.UnsignedTx, Amount: uint64(amount)} + src.Outpoint.Hash = src.Tx.TxHash() + act, err := inst.Build([]*Source{src}, Fee{Dust: 330}) + if amount >= 12000 || amount == 10000 && vector.Predicate == "51" { + require.NoError(t, err) + require.EqualValues(t, amount, act.Tx.UnsignedTx.TxOut[0].Value) + require.Equal(t, "5120"+v.ReceiverProgram, hex.EncodeToString(act.Tx.UnsignedTx.TxOut[0].PkScript)) + } else { + require.ErrorIs(t, err, ErrIneligible) + require.Nil(t, act) + } + } + }) + } +} + +func TestInputPredicate(t *testing.T) { + for _, tc := range []struct { + name, program string + accepted bool + }{ + {"true", "51", true}, + {"empty initial stack", "740087", true}, + {"resolved expression", "$(0x51)", true}, + {"false", "00", false}, + {"noncanonical true", "52", false}, + {"nonminimal numeric true", "020100", false}, + {"extra stack", "5151", false}, + {"empty", "", false}, + {"malformed", "4c02ff", false}, + {"unbalanced branch", "006351", false}, + {"forbidden inactive", "0063cf6851", false}, + {"nonminimal inactive", "00634c01116851", false}, + {"reserved inactive", "0063506851", false}, + {"unknown inactive", "0063ff6851", false}, + {"201 nonpush", strings.Repeat("61", 201) + "51", true}, + {"202 nonpush", strings.Repeat("61", 202) + "51", false}, + {"520 bytes", "4d0002" + strings.Repeat("11", 512) + "7561616151", true}, + {"521 resolved bytes", strings.Repeat("00", 520) + "$(0x11)", false}, + } { + t.Run(tc.name, func(t *testing.T) { + inst, sources := predicateBuild(t, "onchain", tc.program) + act, err := inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + if tc.accepted { + require.NoError(t, err) + require.NotNil(t, act) + } else { + require.ErrorIs(t, err, ErrIneligible) + require.Nil(t, act) + } + }) + } +} + +func TestInputPredicateSecretBinding(t *testing.T) { + doc := edited(t, fixture(t, "minimal.json"), func(m map[string]any) { + m["variables"] = map[string]any{"ciphertext": "bytes"} + m["secrets"] = map[string]any{"program": map[string]any{"type": "bytes", "from": "funds", "ciphertext": ""}} + m["inputs"].([]any)[0].(map[string]any)["predicate"] = "" + }) + tmpl := parseDoc(t, doc) + opened := 0 + inst, err := tmpl.Instantiate(Context{Keys: testKeys(t), Variables: map[string][]byte{"ciphertext": {1}}, Decrypt: func([]byte) ([]byte, error) { + opened++ + return []byte{0x51}, nil + }}) + require.NoError(t, err) + require.Zero(t, opened) + src := sourceWith(t, 10000) + lockTo(src, inst.PkScript(0)) + _, err = inst.Build([]*Source{src}, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + require.NoError(t, err) + require.Equal(t, 1, opened) +} + +func TestInputPredicateBindingValidation(t *testing.T) { + for name, binding := range map[string]any{ + "null": nil, "boolean": true, "number": 1, "output program": map[string]any{"program": "payment"}, + "unknown name": "", "literal oversize": strings.Repeat("00", 521), + } { + t.Run(name, func(t *testing.T) { + doc := edited(t, fixture(t, "minimal.json"), func(m map[string]any) { + m["inputs"].([]any)[0].(map[string]any)["predicate"] = binding + }) + _, err := Parse(doc, nil) + require.ErrorIs(t, err, ErrInvalidTemplate) + }) + } +} + +func TestOfflineReceivePredicate(t *testing.T) { + v := vhtlcVector(t) + unhex := func(s string) []byte { b, err := hex.DecodeString(s); require.NoError(t, err); return b } + tmpl := parseFixture(t, "offline_receive_predicate.json") + require.Equal(t, "9175f4917d1c2e90c4a70d8036e49679a94cef048860ea2f4f337c12ab68609a", tmpl.ID()) + key, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + tree, err := txutils.TapTree(v.Tapscripts).Encode() + require.NoError(t, err) + for _, tc := range []struct { + name, sealed, supplied string + amount int64 + minimum []byte + receiver bool + ok bool + }{ + {"explicit true", "51", "51", 10000, asm.ScriptNum(10000), true, true}, + {"condition met", "cdc902e02ea2", "cdc902e02ea2", 12000, asm.ScriptNum(10000), true, true}, + {"condition unmet", "cdc902e02ea2", "cdc902e02ea2", 10000, asm.ScriptNum(10000), true, false}, + {"substituted program", "cdc902e02ea2", "51", 12000, asm.ScriptNum(10000), true, false}, + {"stripped program", "51", "", 12000, asm.ScriptNum(10000), true, false}, + {"false sealed", "00", "00", 12000, asm.ScriptNum(10000), true, false}, + {"wrong receiver", "51", "51", 12000, asm.ScriptNum(10000), false, false}, + {"underfunded", "51", "51", 9999, asm.ScriptNum(10000), true, false}, + {"missing minimum", "51", "51", 12000, nil, true, false}, + {"nonminimal minimum", "51", "51", 12000, []byte{1, 0}, true, false}, + {"zero minimum", "51", "51", 12000, []byte{}, true, false}, + {"negative minimum", "51", "51", 12000, asm.ScriptNum(-1), true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + receiver := unhex(v.ReceiverProgram) + if !tc.receiver { + receiver = bytes.Repeat([]byte{2}, 32) + } + hash := sha256.Sum256(unhex(tc.sealed)) + terms := append(append(append(unhex(v.Preimage), receiver...), hash[:]...), tc.minimum...) + ciphertext, err := ecies.Encrypt(key.PubKey(), terms, tmpl.ID()) + require.NoError(t, err) + vars := map[string][]byte{"taptree": tree, "receiver_program": unhex(v.ReceiverProgram), "ciphertext": ciphertext, "predicate": unhex(tc.supplied)} + inst, err := tmpl.Instantiate(Context{Keys: testKeys(t), Variables: vars, Decrypt: func(ct []byte) ([]byte, error) { return ecies.Decrypt(key, ct, tmpl.ID()) }}) + require.NoError(t, err) + src := sourceWith(t, tc.amount) + lockTo(src, inst.PkScript(0)) + act, err := inst.Build([]*Source{src}, Fee{Dust: 330}) + if tc.ok { + require.NoError(t, err) + require.EqualValues(t, tc.amount, act.Tx.UnsignedTx.TxOut[0].Value) + require.Equal(t, "5120"+v.ReceiverProgram, hex.EncodeToString(act.Tx.UnsignedTx.TxOut[0].PkScript)) + w, err := txutils.GetArkPsbtFields(act.Tx, 0, txutils.ConditionWitnessField) + require.NoError(t, err) + require.Equal(t, unhex(v.Preimage), w[0][0]) + } else { + require.ErrorIs(t, err, ErrIneligible) + require.Nil(t, act) + require.NotContains(t, err.Error(), hex.EncodeToString(terms)) + } + }) + } + program := unhex(v.ReceiverProgram) + cov, err := asm.Assemble(nonInteractiveClaimCovenant, func(string) (asm.Value, error) { return asm.Value{Type: "bytes32", Raw: program}, nil }) + require.NoError(t, err) + for _, present := range []bool{true, false} { + body := tlv(1, []byte{1}, 2, cov, 3, key.PubKey().SerializeCompressed()) + if present { + body = append(body, tlv(4, []byte{0x51})...) + } + ptx, _ := streamed(t, body) + ptx.UnsignedTx.TxOut[0].PkScript = unhex(v.PkScript) + ptx.Outputs[0].TaprootTapTree = tree + got, err := tmpl.Trigger().Match(ptx, TriggerKeys{Encryption: key.PubKey()}) + if present { + require.NoError(t, err) + require.Equal(t, "51", got[0].Variables["predicate"]) + } else { + require.ErrorIs(t, err, ErrNoMatch) + } + } +} + +func TestInputPredicateFrozenPolicy(t *testing.T) { + for op, cap := range arkade.DefaultComputeLimits() { + if predicateOpcode(op) { + require.Equal(t, cap, predicateComputeLimit(op)) + require.Equal(t, arkade.DefaultAggregateComputeLimits()[op], 4*predicateComputeLimit(op)) + } + } + for _, op := range []byte{arkade.OP_CHECKSIG, arkade.OP_CHECKSIGVERIFY, arkade.OP_CHECKSIGADD, + arkade.OP_SIGHASH, arkade.OP_TXID, arkade.OP_TXWEIGHT, arkade.OP_TUNNEL, arkade.OP_INSPECTPACKET, + arkade.OP_INSPECTOUTPUTVALUE, arkade.OP_INSPECTOUTPUTSCRIPTPUBKEY, arkade.OP_INSPECTNUMOUTPUTS, + arkade.OP_INSPECTINPUTARKADESCRIPTHASH, arkade.OP_INSPECTINPUTARKADEWITNESSHASH, arkade.OP_INSPECTINTENTMESSAGE, + arkade.OP_CHECKMULTISIG, arkade.OP_CHECKMULTISIGVERIFY, arkade.OP_CODESEPARATOR} { + require.Error(t, checkPredicate([]byte{0, arkade.OP_IF, op, arkade.OP_ENDIF, arkade.OP_1}, map[byte]int{})) + } + for op := byte(arkade.OP_INSPECTNUMASSETGROUPS); op <= arkade.OP_INSPECTINASSETLOOKUP; op++ { + require.False(t, predicateOpcode(op)) + } + for _, badPush := range [][]byte{{0x4c, 0}, {1, 1}, {1, 0x81}, {0x4d, 1, 0, 0x11}, {0x4e, 1, 0, 0, 0, 0x11}} { + program := append(append([]byte{0, arkade.OP_IF}, badPush...), arkade.OP_ENDIF, arkade.OP_1) + require.Error(t, checkPredicate(program, map[byte]int{})) + } + for _, op := range []byte{arkade.OP_CHECKSIGFROMSTACK, arkade.OP_ECMUL, arkade.OP_ECMULSCALARVERIFY, arkade.OP_TWEAKVERIFY, arkade.OP_ECPAIRING, arkade.OP_MODEXP} { + program := append(append([]byte{0, arkade.OP_IF}, bytes.Repeat([]byte{op}, predicateComputeLimit(op)+1)...), arkade.OP_ENDIF, arkade.OP_1) + require.ErrorContains(t, checkPredicate(program, map[byte]int{}), "predicate compute limit") + } +} + +func TestInputPredicateSignature(t *testing.T) { + key, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{4}, 32)) + message := sha256.Sum256([]byte("predicate statement")) + signature, err := schnorr.Sign(key, message[:]) + require.NoError(t, err) + for _, valid := range []bool{true, false} { + msg := bytes.Clone(message[:]) + if !valid { + msg[0] ^= 1 + } + program, err := txscript.NewScriptBuilder().AddData(signature.Serialize()).AddData(msg).AddData(schnorr.SerializePubKey(key.PubKey())).AddOp(arkade.OP_CHECKSIGFROMSTACK).Script() + require.NoError(t, err) + inst, sources := predicateBuild(t, "onchain", hex.EncodeToString(program)) + _, err = inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + if valid { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ErrIneligible) + require.NotContains(t, err.Error(), hex.EncodeToString(program)) + } + } +} + +func TestInputPredicateConcurrentBuildBudgets(t *testing.T) { + program := hex.EncodeToString(append(bytes.Repeat([]byte{0x51, 0x51, 0x51, 0xda, 0x75}, 50), 0x51)) + inst, sources := predicateBuild(t, "onchain", program, program, program, program) + errors := make(chan error, 8) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + _, err := inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + errors <- err + }) + } + wg.Wait() + close(errors) + for err := range errors { + require.NoError(t, err) + } +} + +func TestInputPredicateContext(t *testing.T) { + for _, typ := range []string{"onchain", "offchain", "intent"} { + t.Run(typ, func(t *testing.T) { + var programs []string + for slot := range 2 { + index := slot + if typ == "intent" { + index++ + } + program, err := asm.Assemble(strings.Fields(fmt.Sprintf("OP_PUSHCURRENTINPUTINDEX %d OP_EQUALVERIFY OP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE %d OP_EQUAL", index, 10000+slot)), nil) + require.NoError(t, err) + programs = append(programs, hex.EncodeToString(program)) + } + inst, sources := predicateBuild(t, typ, programs...) + _, err := inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + require.NoError(t, err) + }) + } +} + +func TestInputPredicateBudget(t *testing.T) { + program := hex.EncodeToString(append(bytes.Repeat([]byte{0x51, 0x51, 0x51, 0xda, 0x75}, 50), 0x51)) + for _, count := range []int{4, 6} { + inst, sources := predicateBuild(t, "onchain", strings.Split(strings.TrimSuffix(strings.Repeat(program+",", count), ","), ",")...) + _, err := inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + if count == 4 { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ErrIneligible) + } + } +} diff --git a/pkg/template/testdata/ids.json b/pkg/template/testdata/ids.json index 854c6d8..6f9e354 100644 --- a/pkg/template/testdata/ids.json +++ b/pkg/template/testdata/ids.json @@ -8,5 +8,6 @@ "counter_seed.json": "b3536b89a557313946e30706d7d4a49d651279f8a4c2aa1febb96bb0c35db303", "claim.json": "d321b3d9ff490a32c028842d57dd7974f271598f266847b29ffe01231ffaeb00", "offline_receive.json": "290b7a4364950677c1bab1ae25f9f757da864a71e54eb2ad74b3c43133e57e17", + "offline_receive_predicate.json": "9175f4917d1c2e90c4a70d8036e49679a94cef048860ea2f4f337c12ab68609a", "minimal.json": "7b2dca63e79d0e11e202d020384031fbc9acec9e49a42ff92136ac44b31aa3f0" } diff --git a/pkg/template/testdata/offline_receive_amount_predicate_vector.json b/pkg/template/testdata/offline_receive_amount_predicate_vector.json new file mode 100644 index 0000000..53a358d --- /dev/null +++ b/pkg/template/testdata/offline_receive_amount_predicate_vector.json @@ -0,0 +1,13 @@ +{ + "description": "Public test key material; sealed receive terms with an authenticated input predicate", + "templateId": "9175f4917d1c2e90c4a70d8036e49679a94cef048860ea2f4f337c12ab68609a", + "preimage": "0707070707070707070707070707070707070707070707070707070707070707", + "receiverProgram": "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4", + "minimumAmount": "10000", + "recipientSecretKey": "0909090909090909090909090909090909090909090909090909090909090909", + "ephemeralKey": "1111111111111111111111111111111111111111111111111111111111111111", + "nonce": "0a0a0a0a0a0a0a0a0a0a0a0a", + "packet": "AQCfA081W9y3zAr3KO88zrlhXZBoS7Wyyl+FmrDwtwQHWHGqCgoKCgoKCgoKCgoKDhw1eyh4YdG+p98k3DJLIhO+uyH9wNU+Tlg80yhjgMAtsTnjPBJykWEVoeA8egrLLED2jHUeQ8Q5ROcQ47kBqYoDWi9IRWC+XBoyzoQ896NYg4pHsvPAnH8YoiMcxu15OM8Q0Tgi0vlMnJHnGn8xPGSkAwAhAlazKLMMi/WDniQFh0eHlAi9s2JB3JwufGGfqhKykglnBAAGzckC4C6i", + "predicate": "cdc902e02ea2", + "predicateHash": "42797b971682973aafaf2e47f13218d94bbdbbcc500316e201c9114d1f0b4482" +} diff --git a/pkg/template/testdata/offline_receive_predicate.json b/pkg/template/testdata/offline_receive_predicate.json new file mode 100644 index 0000000..ac26b08 --- /dev/null +++ b/pkg/template/testdata/offline_receive_predicate.json @@ -0,0 +1,94 @@ +{ + "format": "template/v1", + "type": "offchain", + "variables": { + "taptree": "bytes", + "receiver_program": "bytes32", + "ciphertext": "bytes", + "predicate": "bytes" + }, + "secrets": {"terms": {"type": "bytes", "from": "htlc", "ciphertext": ""}}, + "inputs": [ + { + "name": "htlc", + "predicate": "", + "contract": { + "definition": { + "contractName": "CovenantClaim", + "constructorInputs": [ + {"name": "preimageHash", "type": "bytes20"}, + {"name": "receiverProgram", "type": "bytes32"} + ], + "structs": [], + "functions": [ + { + "name": "claim", + "arkade": { + "inputs": [], + "asm": [ + "OP_PUSHCURRENTINPUTINDEX", "OP_DUP", "OP_INSPECTOUTPUTSCRIPTPUBKEY", "1", "OP_EQUALVERIFY", + "", "OP_EQUALVERIFY", "OP_INSPECTOUTPUTVALUE", "OP_PUSHCURRENTINPUTINDEX", + "OP_INSPECTINPUTVALUE", "OP_GREATERTHANOREQUAL" + ] + }, + "leaves": [ + { + "name": "claim", + "asm": [ + "OP_SIZE", "32", "OP_EQUALVERIFY", "OP_HASH160", "", "OP_EQUAL", "OP_VERIFY", + "", "OP_CHECKSIGVERIFY", "", "OP_CHECKSIG" + ], + "witness": [ + {"name": "preimage", "type": "bytes32", "encoding": "raw"}, + {"name": "serverSig", "type": "signature", "encoding": "schnorr-64", "injected": true}, + {"name": "emulatorSig", "type": "signature", "encoding": "schnorr-64", "injected": true} + ] + } + ] + } + ] + }, + "arguments": { + "preimageHash": "$( 32 OP_LEFT OP_HASH160)", + "receiverProgram": "" + }, + "taptree": "" + }, + "spend": { + "function": "claim", + "leaf": "claim", + "witness": {"preimage": "$( 32 OP_LEFT)"} + } + } + ], + "outputs": [ + { + "name": "payout", + "index": 0, + "value": {"from": "htlc"}, + "locking": "5120$( OP_SHA256 64 32 OP_SUBSTR OP_EQUALVERIFY 32 32 OP_SUBSTR OP_EQUALVERIFY OP_PUSHCURRENTINPUTINDEX OP_INSPECTINPUTVALUE OP_SIZE 96 OP_SUB OP_RIGHT OP_DUP 0 OP_GREATERTHAN OP_VERIFY OP_GREATERTHANOREQUAL OP_VERIFY )", + "assets": [{"from": "htlc"}] + } + ], + "packets": {"output_index": 1, "unmatched": "drop"}, + "trigger": { + "packet": 4, + "decode": "tlv", + "min_value": 1000, + "require": {"tlv:3": ""}, + "capture": { + "ciphertext": "tlv:1", + "predicate": "tlv:4", + "receiver_program": { + "from": "tlv:2", + "asm": [ + "OP_PUSHCURRENTINPUTINDEX", "OP_DUP", "OP_INSPECTOUTPUTSCRIPTPUBKEY", "1", "OP_EQUALVERIFY", + "", "OP_EQUALVERIFY", "OP_INSPECTOUTPUTVALUE", "OP_PUSHCURRENTINPUTINDEX", + "OP_INSPECTINPUTVALUE", "OP_GREATERTHANOREQUAL" + ] + }, + "taptree": "output:taptree" + }, + "expires_after_seconds": 86400 + } +} diff --git a/pkg/template/testdata/offline_receive_predicate_vector.json b/pkg/template/testdata/offline_receive_predicate_vector.json new file mode 100644 index 0000000..01c2e3b --- /dev/null +++ b/pkg/template/testdata/offline_receive_predicate_vector.json @@ -0,0 +1,13 @@ +{ + "description": "Public test key material; sealed receive terms with an authenticated input predicate", + "templateId": "9175f4917d1c2e90c4a70d8036e49679a94cef048860ea2f4f337c12ab68609a", + "preimage": "0707070707070707070707070707070707070707070707070707070707070707", + "receiverProgram": "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4", + "minimumAmount": "10000", + "recipientSecretKey": "0909090909090909090909090909090909090909090909090909090909090909", + "ephemeralKey": "1111111111111111111111111111111111111111111111111111111111111111", + "nonce": "0a0a0a0a0a0a0a0a0a0a0a0a", + "packet": "AQCfA081W9y3zAr3KO88zrlhXZBoS7Wyyl+FmrDwtwQHWHGqCgoKCgoKCgoKCgoKDhw1eyh4YdG+p98k3DJLIhO+uyH9wNU+Tlg80yhjgMAtsTnjPBJykWEVoeA8egrLLED2jHUeQ8Q5ROcQ47kBqYKSNMquqewMDunx828O5j9QELJqt+65DGA4c2WPDpubOM9hFSmDi6uVdEuk82HuU4pBAwAhAlazKLMMi/WDniQFh0eHlAi9s2JB3JwufGGfqhKykglnBAABUQ==", + "predicate": "51", + "predicateHash": "4ae81572f06e1b88fd5ced7a1a000945432e83e1551e6f721ee9c00b8cc33260" +} From 3dc8845437c74040fcef87d6d225496b7fe0d48c Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 11 Nov 2026 12:08:09 +0100 Subject: [PATCH 2/5] test(template): prove packet predicates through streamed claims --- test/e2e/swap_test.go | 64 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/test/e2e/swap_test.go b/test/e2e/swap_test.go index 9345fe2..a048b28 100644 --- a/test/e2e/swap_test.go +++ b/test/e2e/swap_test.go @@ -5,8 +5,10 @@ package e2e import ( "bytes" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/hex" + "maps" "slices" "testing" "time" @@ -15,11 +17,13 @@ import ( "github.com/arkade-os/arkd/pkg/ark-lib/txutils" clientlib "github.com/arkade-os/arkd/pkg/client-lib" offchaintx "github.com/arkade-os/arkd/pkg/client-lib/offchain-tx" + "github.com/arkade-os/emulator/pkg/arkade" executorv1 "github.com/arkade-os/executor/api-spec/protobuf/gen/executor/v1" "github.com/arkade-os/executor/pkg/template/ecies" "github.com/arkade-os/executor/pkg/template/packets" "github.com/arkade-os/executor/test/daemon" "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript/v2" "github.com/stretchr/testify/require" ) @@ -85,6 +89,43 @@ func TestStreamClaimEnforcesTheSealedTerms(t *testing.T) { neverSpent(t, d, streamFunding(t, d, alice, redirected)+":0") } +func TestStreamClaimEnforcesAnAuthenticatedPredicate(t *testing.T) { + d := start(t) + claim := registerTemplate(t, d, "offline_receive_predicate.json") + _, err := d.admin.RegisterStream(t.Context(), &executorv1.RegisterStreamRequest{TemplateId: claim}) + require.NoError(t, err) + streamActive(t, d, claim) + alice := fundedWallet(t) + predicate := func(minimum int64) []byte { + program, err := txscript.NewScriptBuilder().AddOp(arkade.OP_PUSHCURRENTINPUTINDEX). + AddOp(arkade.OP_INSPECTINPUTVALUE).AddInt64(minimum). + AddOp(arkade.OP_GREATERTHANOREQUAL).Script() + require.NoError(t, err) + return program + } + pay := func() { + swap := newSwap(t, d, claim) + swap.sealPredicateTerms(t, d, claim, predicate(10_000), 10_000) + funding := streamFunding(t, d, alice, swap) + paid := waitForVtxo(t, d, hex.EncodeToString(swap.receiverScript), funding) + require.EqualValues(t, 10_000, paid.Amount) + requirePreimage(t, d, paid, swap) + } + pay() + + blocked := newSwap(t, d, claim) + blocked.sealPredicateTerms(t, d, claim, predicate(10_001), 10_000) + neverSpent(t, d, streamFunding(t, d, alice, blocked)+":0") + require.Empty(t, vtxosAt(t, d, hex.EncodeToString(blocked.receiverScript))) + + substituted := newSwap(t, d, claim) + substituted.sealPredicateTerms(t, d, claim, predicate(10_001), 10_000) + substituted.variables["predicate"] = "51" + neverSpent(t, d, streamFunding(t, d, alice, substituted)+":0") + require.Empty(t, vtxosAt(t, d, hex.EncodeToString(substituted.receiverScript))) + pay() +} + func TestVHTLCRefundAfterLocktime(t *testing.T) { d := start(t) refund := registerTemplate(t, d, "vhtlc_refund.json") @@ -154,6 +195,17 @@ func (s swap) sealTerms(t *testing.T, d executor, templateID, program string, le s.variables["ciphertext"] = seal(t, d, templateID, slices.Concat(s.preimage, terms)) } +func (s swap) sealPredicateTerms(t *testing.T, d executor, templateID string, predicate []byte, least int64) { + t.Helper() + receiver, err := hex.DecodeString(s.variables["receiver_program"]) + require.NoError(t, err) + minimum, err := hex.DecodeString(daemon.ScriptNum(least)) + require.NoError(t, err) + hash := sha256.Sum256(predicate) + s.variables["predicate"] = hex.EncodeToString(predicate) + s.variables["ciphertext"] = seal(t, d, templateID, slices.Concat(s.preimage, receiver, hash[:], minimum)) +} + // swapClaim funds a VHTLC watched under the claim template and waits for its claim. func swapClaim(t *testing.T, d executor) (clientlib.Vtxo, time.Duration) { t.Helper() @@ -183,8 +235,14 @@ func streamFunding(t *testing.T, d executor, alice testWallet, swap swap) string require.NoError(t, err) ciphertext, err := hex.DecodeString(swap.variables["ciphertext"]) require.NoError(t, err) + values := [][]byte{ciphertext, daemon.EnforcePayTo(t, program), key} + if encoded, ok := swap.variables["predicate"]; ok { + predicate, err := hex.DecodeString(encoded) + require.NoError(t, err) + values = append(values, predicate) + } funding, err := alice.SendOffChain(t.Context(), []clientlib.Receiver{{To: addr, Amount: 10_000}}, - offchaintx.WithExtraPacket(packets.Raw(4, daemon.TLV(ciphertext, daemon.EnforcePayTo(t, program), key))), + offchaintx.WithExtraPacket(packets.Raw(4, daemon.TLV(values...))), offchaintx.WithTxOutsTaprootTree(map[string][]byte{hex.EncodeToString(pkScript): tree})) require.NoError(t, err) return funding @@ -252,7 +310,9 @@ func succeededOf(t *testing.T, d executor, id int64) []*executorv1.Attempt { func swapOutput(t *testing.T, d executor, swap swap) (string, []byte, []byte) { t.Helper() keys := keysOf(t, d) - inst := instantiate(t, "vhtlc_claim.json", keys, swap.variables, func([]byte) ([]byte, error) { return swap.preimage, nil }) + variables := maps.Clone(swap.variables) + delete(variables, "predicate") + inst := instantiate(t, "vhtlc_claim.json", keys, variables, func([]byte) ([]byte, error) { return swap.preimage, nil }) tree, err := txutils.TapTree(inst.Tapscripts(0)).Encode() require.NoError(t, err) tapKey, err := schnorr.ParsePubKey(inst.PkScript(0)[2:]) From 4e00700683f1342ec472146dd86d6fef467564a8 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 11 Nov 2026 13:02:08 +0100 Subject: [PATCH 3/5] fix(template): charge predicate resolution against compute caps --- pkg/template/draft.go | 15 +++-- pkg/template/predicate.go | 63 ++++++++++++++++---- pkg/template/predicate_test.go | 103 +++++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/pkg/template/draft.go b/pkg/template/draft.go index 9e8fa0e..273a910 100644 --- a/pkg/template/draft.go +++ b/pkg/template/draft.go @@ -32,10 +32,11 @@ type Source struct { // draft is the transaction expressions see: inputs at their final position, no output; not safe for concurrent use. type draft struct { - tx *wire.MsgTx - sources []*Source // aligned with tx.TxIn; nil for an intent's message input - bound []bool // aligned with tx.TxIn; false for a source whose Tx is not its outpoint's - secrets map[string][]byte // plaintexts opened for this draft + tx *wire.MsgTx + sources []*Source // aligned with tx.TxIn; nil for an intent's message input + bound []bool // aligned with tx.TxIn; false for a source whose Tx is not its outpoint's + secrets map[string][]byte // plaintexts opened for this draft + predicateBudget *predicateBudget } func newDraft(typ Type, sources []*Source) *draft { @@ -65,6 +66,12 @@ func (d *draft) index(slot int) int { } func (d *draft) eval(tokens []string, input int, lookup asm.Lookup) ([]byte, error) { + // Assembly may resolve secrets whose ciphertext bindings evaluate further expressions. + if d.predicateBudget != nil { + if err := d.predicateBudget.expression(tokens); err != nil { + return nil, err + } + } script, err := asm.Assemble(tokens, lookup) if err != nil { return nil, fmt.Errorf("%w: expression: %v", ErrIneligible, err) diff --git a/pkg/template/predicate.go b/pkg/template/predicate.go index e11406c..ac59a01 100644 --- a/pkg/template/predicate.go +++ b/pkg/template/predicate.go @@ -5,21 +5,25 @@ import ( "fmt" "github.com/arkade-os/emulator/pkg/arkade" + "github.com/arkade-os/executor/pkg/template/internal/asm" "github.com/btcsuite/btcd/txscript/v2" ) func (i *Instance) predicates(d *draft) error { programs := make([][]byte, len(i.tmpl.inputs)) total := map[byte]int{} + previous := d.predicateBudget + defer func() { d.predicateBudget = previous }() for slot, in := range i.tmpl.inputs { if in.predicate == nil { continue } + d.predicateBudget = &predicateBudget{total: total} program, err := i.resolve(*in.predicate, d, d.index(slot)) if err != nil { return fmt.Errorf("%w: input %q predicate binding failed", ErrIneligible, in.name) } - if err := checkPredicate(program, total); err != nil { + if err := checkPredicate(program, d.predicateBudget); err != nil { return err } programs[slot] = program @@ -56,13 +60,54 @@ func predicateError(err error) error { return fmt.Errorf("%w: predicate: %s", ErrIneligible, vmError(err)) } -func checkPredicate(program []byte, total map[byte]int) error { +type predicateBudget struct { + input, total map[byte]int +} + +func (b *predicateBudget) charge(op byte) error { + limit := predicateComputeLimit(op) + if limit == 0 { + return nil + } + if b.input == nil { + b.input = map[byte]int{} + } + if b.total == nil { + b.total = map[byte]int{} + } + b.input[op]++ + b.total[op]++ + if b.input[op] > limit { + return fmt.Errorf("%w: predicate compute limit", ErrIneligible) + } + if b.total[op] > 4*limit { + return fmt.Errorf("%w: predicate aggregate compute limit", ErrIneligible) + } + return nil +} + +func (b *predicateBudget) expression(tokens []string) error { + for _, token := range tokens { + if asm.DecimalTok.MatchString(token) || asm.HexTok.MatchString(token) || asm.Placeholder.MatchString(token) { + continue + } + op, err := asm.Opcode(token) + if err != nil { + return fmt.Errorf("%w: predicate expression opcode", ErrIneligible) + } + if err := b.charge(op); err != nil { + return err + } + } + return nil +} + +func checkPredicate(program []byte, budget *predicateBudget) error { if len(program) < 1 || len(program) > 520 { return fmt.Errorf("%w: predicate size outside 1..520", ErrIneligible) } - counts := map[byte]int{} // Raw engines have no taproot context: the pinned VM's compute budget does not charge them. - // Loopless programs permit conservative counting of every branch before any predicate runs. + // Resolution expressions and returned programs share conservative counts of every branch. ops := 0 tokens := txscript.MakeScriptTokenizer(0, program) for tokens.Next() { @@ -82,14 +127,8 @@ func checkPredicate(program []byte, total map[byte]int) error { if ops > 201 { return fmt.Errorf("%w: predicate opcode limit", ErrIneligible) } - counts[op]++ - total[op]++ - limit := predicateComputeLimit(op) - if limit > 0 && counts[op] > limit { - return fmt.Errorf("%w: predicate compute limit", ErrIneligible) - } - if limit > 0 && total[op] > 4*limit { - return fmt.Errorf("%w: predicate aggregate compute limit", ErrIneligible) + if err := budget.charge(op); err != nil { + return err } } if err := tokens.Err(); err != nil { diff --git a/pkg/template/predicate_test.go b/pkg/template/predicate_test.go index a0e621b..a64a9ff 100644 --- a/pkg/template/predicate_test.go +++ b/pkg/template/predicate_test.go @@ -261,18 +261,18 @@ func TestInputPredicateFrozenPolicy(t *testing.T) { arkade.OP_INSPECTOUTPUTVALUE, arkade.OP_INSPECTOUTPUTSCRIPTPUBKEY, arkade.OP_INSPECTNUMOUTPUTS, arkade.OP_INSPECTINPUTARKADESCRIPTHASH, arkade.OP_INSPECTINPUTARKADEWITNESSHASH, arkade.OP_INSPECTINTENTMESSAGE, arkade.OP_CHECKMULTISIG, arkade.OP_CHECKMULTISIGVERIFY, arkade.OP_CODESEPARATOR} { - require.Error(t, checkPredicate([]byte{0, arkade.OP_IF, op, arkade.OP_ENDIF, arkade.OP_1}, map[byte]int{})) + require.Error(t, checkPredicate([]byte{0, arkade.OP_IF, op, arkade.OP_ENDIF, arkade.OP_1}, &predicateBudget{})) } for op := byte(arkade.OP_INSPECTNUMASSETGROUPS); op <= arkade.OP_INSPECTINASSETLOOKUP; op++ { require.False(t, predicateOpcode(op)) } for _, badPush := range [][]byte{{0x4c, 0}, {1, 1}, {1, 0x81}, {0x4d, 1, 0, 0x11}, {0x4e, 1, 0, 0, 0, 0x11}} { program := append(append([]byte{0, arkade.OP_IF}, badPush...), arkade.OP_ENDIF, arkade.OP_1) - require.Error(t, checkPredicate(program, map[byte]int{})) + require.Error(t, checkPredicate(program, &predicateBudget{})) } for _, op := range []byte{arkade.OP_CHECKSIGFROMSTACK, arkade.OP_ECMUL, arkade.OP_ECMULSCALARVERIFY, arkade.OP_TWEAKVERIFY, arkade.OP_ECPAIRING, arkade.OP_MODEXP} { program := append(append([]byte{0, arkade.OP_IF}, bytes.Repeat([]byte{op}, predicateComputeLimit(op)+1)...), arkade.OP_ENDIF, arkade.OP_1) - require.ErrorContains(t, checkPredicate(program, map[byte]int{}), "predicate compute limit") + require.ErrorContains(t, checkPredicate(program, &predicateBudget{}), "predicate compute limit") } } @@ -300,7 +300,7 @@ func TestInputPredicateSignature(t *testing.T) { } func TestInputPredicateConcurrentBuildBudgets(t *testing.T) { - program := hex.EncodeToString(append(bytes.Repeat([]byte{0x51, 0x51, 0x51, 0xda, 0x75}, 50), 0x51)) + program := predicateWork(16) + hex.EncodeToString(append(bytes.Repeat([]byte{0x51, 0x51, 0x51, 0xda, 0x75}, 48), 0x51)) inst, sources := predicateBuild(t, "onchain", program, program, program, program) errors := make(chan error, 8) var wg sync.WaitGroup @@ -349,3 +349,98 @@ func TestInputPredicateBudget(t *testing.T) { } } } + +func predicateWork(n int) string { + return "$(" + strings.Repeat("1 1 1 OP_MODEXP OP_DROP ", n) + "0)" +} + +func TestPredicateResolutionBudget(t *testing.T) { + raw := func(n int) string { + return hex.EncodeToString(append(bytes.Repeat([]byte{0x51, 0x51, 0x51, 0xda, 0x75}, n), 0x51)) + } + for _, tc := range []struct { + name, binding string + inputs int + accepted bool + }{ + {"empty expressions at limit", strings.Repeat(predicateWork(32), 2) + "51", 1, true}, + {"empty expressions over limit", strings.Repeat(predicateWork(32), 3) + "51", 1, false}, + {"combined at limit", predicateWork(32) + raw(32), 1, true}, + {"combined over limit", predicateWork(33) + raw(32), 1, false}, + {"aggregate at limit", predicateWork(64) + "51", 4, true}, + {"aggregate over limit", predicateWork(52) + "51", 5, false}, + {"opcode bytes are data", predicateWork(64) + "$(0xda OP_DROP 0)51", 1, true}, + {"inactive resolution over limit", predicateWork(64) + "$(0 OP_IF 1 1 1 OP_MODEXP OP_DROP OP_ENDIF 0)51", 1, false}, + } { + t.Run(tc.name, func(t *testing.T) { + programs := make([]string, tc.inputs) + for k := range programs { + programs[k] = tc.binding + } + inst, sources := predicateBuild(t, "onchain", programs...) + act, err := inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + if tc.accepted { + require.NoError(t, err) + require.NotNil(t, act) + } else { + require.ErrorIs(t, err, ErrIneligible, "action returned: %v", act != nil) + require.Nil(t, act) + } + }) + } +} + +func TestPredicateResolutionNestedBudget(t *testing.T) { + for _, tc := range []struct { + name, predicate, ciphertext string + accepted bool + }{ + {"nested ciphertext at limit", "", predicateWork(64) + "51", true}, + {"nested ciphertext over limit", "", predicateWork(32) + predicateWork(33) + "51", false}, + {"nested combined over limit", predicateWork(32) + "", predicateWork(33) + "51", false}, + {"reject before assembly lookup", predicateWork(64) + "$( OP_DROP 1 1 1 OP_MODEXP OP_DROP 0)51", "51", false}, + } { + t.Run(tc.name, func(t *testing.T) { + doc := edited(t, fixture(t, "minimal.json"), func(m map[string]any) { + m["secrets"] = map[string]any{"program": map[string]any{"type": "bytes", "from": "funds", "ciphertext": tc.ciphertext}} + in := m["inputs"].([]any)[0].(map[string]any) + in["predicate"] = predicateWork(64) + "51" + m["inputs"] = append(m["inputs"].([]any), map[string]any{"name": "other", "contract": in["contract"], "spend": in["spend"], "predicate": tc.predicate}) + out := m["outputs"].([]any)[0].(map[string]any) + m["outputs"] = append(m["outputs"].([]any), map[string]any{"name": "other", "index": 1, "value": map[string]any{"from": "other"}, "locking": out["locking"]}) + }) + _, sources := predicateBuild(t, "onchain", "51", "51") + opened := 0 + inst, err := parseDoc(t, doc).Instantiate(Context{Keys: testKeys(t), Sources: sources, Decrypt: func(ct []byte) ([]byte, error) { opened++; return ct, nil }}) + require.NoError(t, err) + act, err := inst.Build(sources, Fee{Dust: 330, Onchain: func(int) uint64 { return 0 }}) + if tc.accepted { + require.NoError(t, err) + require.NotNil(t, act) + require.Equal(t, 1, opened) + } else { + require.ErrorIs(t, err, ErrIneligible, "action returned: %v", act != nil) + require.Nil(t, act) + require.Zero(t, opened) + } + }) + } +} + +func TestPredicateResolutionPhaseEnds(t *testing.T) { + for _, result := range []string{"51", "00"} { + inst, sources := predicateBuild(t, "onchain", predicateWork(64)+result) + d := newDraft(Onchain, sources) + err := inst.predicates(d) + if result == "51" { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ErrIneligible) + } + ordinary, err := parseByteTemplate(predicateWork(64) + predicateWork(64)) + require.NoError(t, err) + value, err := inst.resolve(ordinary, d, 0) + require.NoError(t, err) + require.Empty(t, value) + } +} From a456d988f5012b742e3882b8cb2d14f8ae3c44cd Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 11 Nov 2026 13:02:09 +0100 Subject: [PATCH 4/5] test(e2e): await funding output projection --- test/e2e/helpers_test.go | 64 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index fe82f04..5fe946b 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "io/fs" "net/http" "os" @@ -15,6 +16,7 @@ import ( "path/filepath" "slices" "strings" + "sync/atomic" "testing" "time" @@ -34,6 +36,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/v2" "github.com/btcsuite/btcd/wire/v2" log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -175,12 +178,65 @@ func neverSpent(t *testing.T, d executor, outpoint string) { t.Helper() op, err := wire.NewOutPointFromString(outpoint) require.NoError(t, err) - spent := func() bool { + read := func() (clientlib.Vtxo, error) { resp, err := d.indexer.GetVtxos(t.Context(), clientlib.WithOutpoints([]clientlib.Outpoint{{Txid: op.Hash.String(), VOut: op.Index}})) - return err != nil || len(resp.Vtxos) != 1 || resp.Vtxos[0].Spent + if err != nil { + return clientlib.Vtxo{}, fmt.Errorf("read funding output %s: %w", outpoint, err) + } + if len(resp.Vtxos) != 1 { + return clientlib.Vtxo{}, fmt.Errorf("funding output %s: expected one vtxo, got %d", outpoint, len(resp.Vtxos)) + } + v := resp.Vtxos[0] + if v.Txid != op.Hash.String() || v.VOut != op.Index { + return clientlib.Vtxo{}, fmt.Errorf("funding output %s: indexer returned %s:%d", outpoint, v.Txid, v.VOut) + } + return v, nil } - require.False(t, spent()) - require.Never(t, spent, 25*time.Second, time.Second, "%s was spent or unreadable", outpoint) + var initial clientlib.Vtxo + require.EventuallyWithT(t, func(c *assert.CollectT) { + var err error + initial, err = read() + require.NoError(c, err) + }, 30*time.Second, 200*time.Millisecond, "funding output %s never became readable", outpoint) + require.False(t, initial.Spent, "funding output %s was already spent when first indexed", outpoint) + require.Never(t, func() bool { + v, err := read() + if err != nil { + t.Log(err) + return true + } + if v.Spent { + t.Logf("funding output %s became spent", outpoint) + return true + } + return false + }, 25*time.Second, time.Second, "funding output %s was spent or became unreadable", outpoint) +} + +type fundingProjectionIndexer struct { + clientlib.Indexer + read func() (*clientlib.VtxosResponse, error) +} + +func (i fundingProjectionIndexer) GetVtxos(context.Context, ...clientlib.GetVtxosOption) (*clientlib.VtxosResponse, error) { + return i.read() +} + +func TestNeverSpentWaitsForFundingProjection(t *testing.T) { + var reads atomic.Int32 + v := clientlib.Vtxo{Outpoint: clientlib.Outpoint{Txid: strings.Repeat("01", 32)}, Amount: 10_000} + indexer := fundingProjectionIndexer{read: func() (*clientlib.VtxosResponse, error) { + switch reads.Add(1) { + case 1: + return &clientlib.VtxosResponse{}, nil + case 2: + return nil, errors.New("indexer temporarily unavailable") + default: + return &clientlib.VtxosResponse{Vtxos: []clientlib.Vtxo{v}}, nil + } + }} + neverSpent(t, executor{indexer: indexer}, v.Txid+":0") + require.Greater(t, reads.Load(), int32(3), "the output must remain under observation after becoming readable") } func faucet(t *testing.T, address string, sats int64) { From b560e062b28337bad825b705431fe8018bfe7659 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 11 Nov 2026 13:39:45 +0100 Subject: [PATCH 5/5] fix(stream): isolate rejected inputs from template failures --- internal/core/application/execution/batch.go | 2 +- .../application/execution/eligibility_test.go | 168 ++++++++++++++++++ .../core/application/execution/offchain.go | 18 +- test/e2e/swap_test.go | 7 + 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 internal/core/application/execution/eligibility_test.go diff --git a/internal/core/application/execution/batch.go b/internal/core/application/execution/batch.go index c34b8ee..01b1d2a 100644 --- a/internal/core/application/execution/batch.go +++ b/internal/core/application/execution/batch.go @@ -230,7 +230,7 @@ func (b *batchWorker) intentOf(ctx context.Context, inst *delegationapp.Instance } in, err := execution.BuildIntent(inst.Template, inst.Delegation.Inputs, e.Coins, txs, template.Fee{Intent: info.IntentFees, Dust: info.Dust}, s.cosignerKey.PubKey()) if err != nil { - return nil, err + return nil, localBuildError(inst, err) } if in.HasArkadePacket { if in.Proof, err = s.emulator.SubmitIntent(ctx, *in); err != nil { diff --git a/internal/core/application/execution/eligibility_test.go b/internal/core/application/execution/eligibility_test.go new file mode 100644 index 0000000..4d790a0 --- /dev/null +++ b/internal/core/application/execution/eligibility_test.go @@ -0,0 +1,168 @@ +package execution + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + delegationapp "github.com/arkade-os/executor/internal/core/application/delegation" + registryapp "github.com/arkade-os/executor/internal/core/application/registry" + "github.com/arkade-os/executor/internal/core/domain/delegation" + "github.com/arkade-os/executor/internal/core/domain/execution" + "github.com/arkade-os/executor/internal/core/domain/registry" + "github.com/arkade-os/executor/internal/core/ports" + "github.com/arkade-os/executor/pkg/template" + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +func TestStreamBuildFailureClassification(t *testing.T) { + for _, tc := range []struct { + name string + err error + streamID int64 + charge bool + }{ + {"stream ineligible", template.ErrIneligible, 1, false}, + {"client ineligible", template.ErrIneligible, 0, true}, + {"bad transaction", execution.ErrBadTx, 1, true}, + {"refusal", refused{errors.New("rejected")}, 1, true}, + {"backend error", errors.New("backend unavailable"), 1, false}, + {"deadline", context.DeadlineExceeded, 1, false}, + {"ineligible bad transaction", errors.Join(template.ErrIneligible, execution.ErrBadTx), 1, true}, + {"ineligible refusal", errors.Join(template.ErrIneligible, refused{errors.New("rejected")}), 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + inst := &delegationapp.Instance{Delegation: &delegation.Delegation{StreamID: tc.streamID}} + err := localBuildError(inst, tc.err) + require.EqualError(t, err, tc.err.Error()) + require.ErrorIs(t, err, tc.err) + require.Equal(t, tc.charge, chargesTemplate(fmt.Errorf("worker: %w", err))) + }) + } +} + +func TestStreamEligibilityDoesNotDisableTemplate(t *testing.T) { + for _, typ := range []template.Type{template.Offchain, template.Intent} { + for _, streamID := range []int64{0, 1} { + t.Run(fmt.Sprintf("%s/stream_%d", typ, streamID), func(t *testing.T) { + var doc map[string]any + require.NoError(t, json.Unmarshal(fixture(t, "minimal.json"), &doc)) + doc["type"] = string(typ) + delete(doc, "fees") + doc["inputs"].([]any)[0].(map[string]any)["predicate"] = "00" + raw, err := json.Marshal(doc) + require.NoError(t, err) + tmpl, err := template.Parse(raw, nil) + require.NoError(t, err) + inst := instance(t, raw, nil) + d := delegation.Delegation{ID: 1, StreamID: streamID, TemplateID: tmpl.ID(), Status: delegation.StatusActive} + d.Inputs = []delegation.Input{{Name: "funds", Script: hex.EncodeToString(inst.PkScript(0)), Tapscripts: inst.Tapscripts(0)}} + source := wire.NewMsgTx(3) + source.AddTxIn(&wire.TxIn{}) + source.AddTxOut(wire.NewTxOut(10000, inst.PkScript(0))) + coin := delegation.Coin{Outpoint: source.TxHash().String() + ":0", DelegationID: d.ID, Script: d.Inputs[0].Script, Amount: 10000, Status: delegation.Present} + templates := &eligibilityTemplates{row: registry.Template{ID: tmpl.ID(), Document: raw, Status: registry.TemplateStatusActive}} + registryService := registryapp.NewService(nil, templates, registryapp.Limits{}) + key := testKey(1) + delegations := delegationapp.NewService(registryService, &eligibilityDelegations{row: d}, nil, nil, key.PubKey(), nil, key.PubKey(), key.PubKey(), arklib.BitcoinRegTest, delegationapp.Limits{}) + coins, attempts := &eligibilityCoins{}, &eligibilityAttempts{} + s := &Service{ctx: t.Context(), delegations: delegations, templates: registryService, coins: coins, attempts: attempts, + indexer: &eligibilityIndexer{tx: source}, ark: &eligibilitySigner{}, cosignerKey: key, info: ports.ArkInfo{Dust: 330}, limits: Limits{TemplateMaxFailures: 10}} + bound := &delegationapp.Instance{Delegation: &d, Template: inst, Type: typ} + if typ == template.Offchain { + _, _, err = s.build(t.Context(), bound, []delegation.Coin{coin}) + } else { + _, err = (&batchWorker{s: s}).intentOf(t.Context(), bound, execution.Entry{Coins: []delegation.Coin{coin}}) + } + require.ErrorIs(t, err, template.ErrIneligible) + require.EqualError(t, err, "build: ineligible: predicate must return one canonical true") + wrapped := fmt.Errorf("worker: %w", err) + for attempt := range 11 { + s.failed(attemptFailed{attempt: delegation.Attempt{DelegationID: 1, Coins: []string{coin.Outpoint}, Status: delegation.Submitted}, err: wrapped, retryIn: 5 * time.Second}, now.Add(time.Duration(attempt)*time.Minute)) + } + require.Len(t, attempts.rows, 11) + require.Len(t, coins.ready, 11) + for attempt, at := range coins.ready { + require.Equal(t, now.Add(time.Duration(attempt)*time.Minute+5*time.Second), at) + require.Equal(t, delegation.Failed, attempts.rows[attempt].Status) + require.Equal(t, []string{coin.Outpoint}, attempts.rows[attempt].Coins) + } + if streamID != 0 { + require.Zero(t, templates.row.Failures) + require.Zero(t, s.templateFailures) + _, err := registryService.Usable(t.Context(), tmpl.ID()) + require.NoError(t, err) + } else { + require.Equal(t, 11, templates.row.Failures) + require.EqualValues(t, 11, s.templateFailures) + require.Equal(t, registry.TemplateStatusDisabled, templates.row.Status) + } + }) + } + } +} + +type eligibilityIndexer struct { + ports.Indexer + tx *wire.MsgTx +} + +func (r *eligibilityIndexer) VirtualTxs(context.Context, []string) (map[string]*wire.MsgTx, error) { + return map[string]*wire.MsgTx{r.tx.TxHash().String(): r.tx}, nil +} + +type eligibilitySigner struct{ ports.Signer } + +func (*eligibilitySigner) Info(context.Context) (ports.ArkInfo, error) { + return ports.ArkInfo{Dust: 330}, nil +} + +type eligibilityTemplates struct { + registry.TemplateRepository + row registry.Template +} + +func (r *eligibilityTemplates) Get(context.Context, string) (*registry.Template, error) { + row := r.row + return &row, nil +} +func (r *eligibilityTemplates) AddOrUpdate(_ context.Context, row *registry.Template) error { + r.row = *row + return nil +} + +type eligibilityDelegations struct { + delegation.DelegationRepository + row delegation.Delegation +} + +func (r *eligibilityDelegations) Get(context.Context, int64) (*delegation.Delegation, error) { + row := r.row + return &row, nil +} + +type eligibilityAttempts struct { + delegation.AttemptRepository + rows []delegation.Attempt +} + +func (r *eligibilityAttempts) AddOrUpdate(_ context.Context, row *delegation.Attempt) error { + r.rows = append(r.rows, *row) + return nil +} + +type eligibilityCoins struct { + delegation.CoinRepository + ready []time.Time +} + +func (r *eligibilityCoins) Release(_ context.Context, _ []string, ready time.Time) error { + r.ready = append(r.ready, ready) + return nil +} diff --git a/internal/core/application/execution/offchain.go b/internal/core/application/execution/offchain.go index dbce4ee..00f2c52 100644 --- a/internal/core/application/execution/offchain.go +++ b/internal/core/application/execution/offchain.go @@ -84,7 +84,7 @@ func (s *Service) build(ctx context.Context, inst *delegationapp.Instance, coins fee := template.Fee{Intent: s.info.IntentFees, Dust: s.info.Dust} built, err := execution.BuildOffchain(inst.Template, inst.Delegation.Inputs, coins, txs, fee, s.info.CheckpointTapscript) if err != nil { - return nil, nil, err + return nil, nil, localBuildError(inst, err) } sources := make([]*wire.MsgTx, len(coins)) for i, c := range coins { @@ -157,11 +157,25 @@ func (r refused) Unwrap() error { return r.error } func (refused) Is(target error) bool { return target == errRefused } +type streamIneligible struct{ error } + +func (e streamIneligible) Unwrap() error { return e.error } + +func localBuildError(inst *delegationapp.Instance, err error) error { + if inst.Delegation.StreamID != 0 && errors.Is(err, template.ErrIneligible) { + return streamIneligible{err} + } + return err +} + // chargesTemplate says a failed attempt counts against its template: the template could not build // it, or arkd or the emulator refused it. Outages, the daemon's own shutdown and sources fees or dust refuse do not. func chargesTemplate(err error) bool { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, template.ErrUnaffordable) { return false } - return errors.Is(err, template.ErrIneligible) || errors.Is(err, execution.ErrBadTx) || errors.Is(err, errRefused) + // Stream inputs are untrusted; local ineligibility does not indict their shared template. + var streamed streamIneligible + return errors.Is(err, execution.ErrBadTx) || errors.Is(err, errRefused) || + errors.Is(err, template.ErrIneligible) && !errors.As(err, &streamed) } diff --git a/test/e2e/swap_test.go b/test/e2e/swap_test.go index a048b28..25cc9b4 100644 --- a/test/e2e/swap_test.go +++ b/test/e2e/swap_test.go @@ -87,6 +87,13 @@ func TestStreamClaimEnforcesTheSealedTerms(t *testing.T) { redirected.sealTerms(t, d, claim, swap.variables["receiver_program"], 10_000) neverSpent(t, d, streamFunding(t, d, alice, short)+":0") neverSpent(t, d, streamFunding(t, d, alice, redirected)+":0") + + control := newSwap(t, d, claim) + control.sealTerms(t, d, claim, control.variables["receiver_program"], 10_000) + controlFunding := streamFunding(t, d, alice, control) + controlPaid := waitForVtxo(t, d, hex.EncodeToString(control.receiverScript), controlFunding) + require.EqualValues(t, 10_000, controlPaid.Amount) + requirePreimage(t, d, controlPaid, control) } func TestStreamClaimEnforcesAnAuthenticatedPredicate(t *testing.T) {