Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ The service can be configured using environment variables:
| `EMULATOR_TLS_EXTRA_DOMAINS` | Additional domains for TLS cert | [] |
| `EMULATOR_LOG_LEVEL` | Log level (0-6) | 4 (Debug) |
| `EMULATOR_ARKD_URL` | URL of the `arkd` instance used for attempted finalization in [`SubmitTx`](#submittx) | Required |
| `EMULATOR_COMPUTE_LIMITS` | Comma-separated `OPCODE=limit` overrides for per-input opcode execution caps, for example `OP_ECPAIRING=8,OP_MODEXP=128`. Overrides are applied on top of defaults; use an empty value such as `OP_ECADD=` to remove a default cap. | Default compute limits |

## Development

Expand Down
1 change: 1 addition & 0 deletions internal/application/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func (s *service) SubmitIntent(ctx context.Context, intent Intent) (*psbt.Packet
ptx.UnsignedTx,
prevOutFetcher,
inputIndex,
arkade.WithExactComputeLimits(s.computeLimits),
); err != nil {
log.WithError(err).WithField("input_index", inputIndex).Error("arkade script execution failed")
return nil, fmt.Errorf("failed to execute arkade script at input %d: %w", inputIndex, err)
Expand Down
7 changes: 6 additions & 1 deletion internal/application/onchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ func (s *service) SubmitOnchainTx(ctx context.Context, tx OnchainTx) (*psbt.Pack
}

log.Debugf("executing arkade script: %x", script.Script())
if err := script.Execute(ptx.UnsignedTx, prevOutFetcher, inputIndex); err != nil {
if err := script.Execute(
ptx.UnsignedTx,
prevOutFetcher,
inputIndex,
arkade.WithExactComputeLimits(s.computeLimits),
); err != nil {
return nil, fmt.Errorf("failed to execute arkade script: %w vin=%d", err, inputIndex)
}
log.Debugf("execution of %x succeeded", script.Script())
Expand Down
5 changes: 4 additions & 1 deletion internal/application/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/arkade-os/arkd/pkg/ark-lib/intent"
"github.com/arkade-os/arkd/pkg/ark-lib/tree"
"github.com/arkade-os/emulator/pkg/arkade"
"github.com/arkade-os/go-sdk/client"
grpcclient "github.com/arkade-os/go-sdk/client/grpc"
"github.com/btcsuite/btcd/btcec/v2"
Expand Down Expand Up @@ -60,9 +61,10 @@ type service struct {
deprecatedPublicKeys []string
arkdClient client.TransportClient
arkdPubKey *btcec.PublicKey
computeLimits arkade.ComputeLimits
}

func New(ctx context.Context, secretKey *btcec.PrivateKey, deprecatedKeys []*btcec.PrivateKey, arkdURL string) (Service, error) {
func New(ctx context.Context, secretKey *btcec.PrivateKey, deprecatedKeys []*btcec.PrivateKey, arkdURL string, computeLimits arkade.ComputeLimits) (Service, error) {
if secretKey == nil {
return nil, fmt.Errorf("current signer key is required")
}
Expand Down Expand Up @@ -111,6 +113,7 @@ func New(ctx context.Context, secretKey *btcec.PrivateKey, deprecatedKeys []*btc
deprecatedPublicKeys: deprecatedPublicKeys,
arkdClient: arkdClient,
arkdPubKey: arkdPubKey,
computeLimits: computeLimits,
}, nil
}

Expand Down
1 change: 1 addition & 0 deletions internal/application/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func (s *service) SubmitTx(ctx context.Context, tx OffchainTx) (*OffchainTx, err
arkPtx.UnsignedTx,
prevOutFetcher,
inputIndex,
arkade.WithExactComputeLimits(s.computeLimits),
); err != nil {
return nil, fmt.Errorf("failed to execute arkade script: %w vin=%d", err, inputIndex)
}
Expand Down
58 changes: 57 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"context"
"encoding/hex"
"fmt"
"strconv"
"strings"

arklib "github.com/arkade-os/arkd/pkg/ark-lib"
"github.com/arkade-os/emulator/internal/application"
"github.com/arkade-os/emulator/pkg/arkade"
"github.com/btcsuite/btcd/btcec/v2"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
Expand All @@ -23,6 +25,7 @@ const (
TLSExtraDomains = "TLS_EXTRA_DOMAINS"
LogLevel = "LOG_LEVEL"
ArkdURL = "ARKD_URL"
ComputeLimits = "COMPUTE_LIMITS"
Comment thread
msinkec marked this conversation as resolved.
)

var (
Expand All @@ -43,6 +46,7 @@ type Config struct {
TLSExtraIPs []string
TLSExtraDomains []string
ArkdURL string
ComputeLimits arkade.ComputeLimits
}

func LoadConfig() (*Config, error) {
Expand Down Expand Up @@ -84,6 +88,11 @@ func LoadConfig() (*Config, error) {
logLevel := viper.GetInt(LogLevel)
log.SetLevel(log.Level(logLevel))

computeLimits, err := parseComputeLimits(viper.GetString(ComputeLimits))
Comment thread
louisinger marked this conversation as resolved.
if err != nil {
return nil, err
}

cfg := &Config{
CurrentKey: currentKey,
DeprecatedKeys: deprecatedKeys,
Expand All @@ -93,13 +102,60 @@ func LoadConfig() (*Config, error) {
TLSExtraIPs: viper.GetStringSlice(TLSExtraIPs),
TLSExtraDomains: viper.GetStringSlice(TLSExtraDomains),
ArkdURL: viper.GetString(ArkdURL),
ComputeLimits: computeLimits,
}
if cfg.ArkdURL == "" {
return nil, fmt.Errorf("missing arkd url")
}
return cfg, nil
}

// parseComputeLimits builds the per-input opcode compute brake from the
// EMULATOR_COMPUTE_LIMITS override, applied on top of the engine defaults. The
// override is a comma-separated list of OPCODE=limit pairs, e.g.
// "OP_ECPAIRING=8,OP_MODEXP=128"; an empty value yields the defaults unchanged.
// It errors on an unknown opcode name, a non-integer limit, or a negative
// limit.
func parseComputeLimits(raw string) (arkade.ComputeLimits, error) {
limits := arkade.DefaultComputeLimits()
if strings.TrimSpace(raw) == "" {
return limits, nil
}

for pair := range strings.SplitSeq(raw, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
return nil, fmt.Errorf(
"invalid empty compute limit override in %q", raw)
}
name, value, ok := strings.Cut(pair, "=")
if !ok {
return nil, fmt.Errorf(
"invalid compute limit override %q, want OPCODE=limit", pair)
}
name = strings.TrimSpace(name)
op, ok := arkade.OpcodeByName[name]
if !ok {
return nil, fmt.Errorf("unknown opcode %q in compute limits", name)
}
value = strings.TrimSpace(value)
if value == "" {
delete(limits, op)
continue
}
limit, err := strconv.Atoi(value)
if err != nil {
return nil, fmt.Errorf("invalid limit for opcode %q: %w", name, err)
}
limits[op] = limit
}

if err := limits.Validate(); err != nil {
return nil, err
}
return limits, nil
}

func parsePrivateKey(keyHex, name string) (*btcec.PrivateKey, error) {
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
Expand All @@ -124,5 +180,5 @@ func parsePrivateKey(keyHex, name string) (*btcec.PrivateKey, error) {
}

func (c *Config) AppService(ctx context.Context) (application.Service, error) {
return application.New(ctx, c.CurrentKey, c.DeprecatedKeys, c.ArkdURL)
return application.New(ctx, c.CurrentKey, c.DeprecatedKeys, c.ArkdURL, c.ComputeLimits)
}
85 changes: 85 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math/big"
"testing"

"github.com/arkade-os/emulator/pkg/arkade"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -267,3 +268,87 @@ func TestLoadConfigIgnoresOldEnvPrefix(t *testing.T) {
_, err := LoadConfig()
require.Error(t, err)
}

func TestParseComputeLimitsEmptyReturnsDefaults(t *testing.T) {
got, err := parseComputeLimits("")
require.NoError(t, err)
require.Equal(t, arkade.DefaultComputeLimits(), got)
}

func TestParseComputeLimitsOverridesSingleOpcode(t *testing.T) {
got, err := parseComputeLimits("OP_ECPAIRING=8")
require.NoError(t, err)

require.Equal(t, 8, got[arkade.OP_ECPAIRING])
// Unspecified opcodes keep their defaults.
require.Equal(t, arkade.DefaultComputeLimits()[arkade.OP_MODEXP],
got[arkade.OP_MODEXP])
}

func TestParseComputeLimitsOverridesMultipleOpcodesWithSpaces(t *testing.T) {
got, err := parseComputeLimits(" OP_ECPAIRING=8 , OP_MODEXP=128 ")
require.NoError(t, err)

require.Equal(t, 8, got[arkade.OP_ECPAIRING])
require.Equal(t, 128, got[arkade.OP_MODEXP])
}

func TestParseComputeLimitsEmptyValueRemovesLimit(t *testing.T) {
got, err := parseComputeLimits("OP_ECADD=")
require.NoError(t, err)

_, ok := got[arkade.OP_ECADD]
require.False(t, ok)
require.Equal(t, arkade.DefaultComputeLimits()[arkade.OP_ECPAIRING],
got[arkade.OP_ECPAIRING])
}

func TestParseComputeLimitsUnknownOpcodeErrors(t *testing.T) {
_, err := parseComputeLimits("OP_NOT_A_REAL_OPCODE=5")
require.ErrorContains(t, err, "OP_NOT_A_REAL_OPCODE")
}

func TestParseComputeLimitsNonIntegerErrors(t *testing.T) {
_, err := parseComputeLimits("OP_ECPAIRING=lots")
require.Error(t, err)
}

func TestParseComputeLimitsMalformedPairErrors(t *testing.T) {
_, err := parseComputeLimits("OP_ECPAIRING")
require.Error(t, err)
}

func TestParseComputeLimitsEmptyPairErrors(t *testing.T) {
_, err := parseComputeLimits(",")
require.Error(t, err)
}

func TestParseComputeLimitsTrailingCommaErrors(t *testing.T) {
_, err := parseComputeLimits("OP_ECPAIRING=8,")
require.Error(t, err)
}

func TestParseComputeLimitsNegativeErrors(t *testing.T) {
_, err := parseComputeLimits("OP_ECPAIRING=-1")
require.Error(t, err)
}

func TestLoadConfigParsesComputeLimitsOverride(t *testing.T) {
cfg, err := loadConfigForTest(t, map[string]string{
"EMULATOR_SECRET_KEY": testKeyHex(1),
"EMULATOR_ARKD_URL": "http://arkd:7070",
"EMULATOR_COMPUTE_LIMITS": "OP_ECPAIRING=8,OP_MODEXP=128",
})
require.NoError(t, err)
require.Equal(t, 8, cfg.ComputeLimits[arkade.OP_ECPAIRING])
require.Equal(t, 128, cfg.ComputeLimits[arkade.OP_MODEXP])
}

func TestLoadConfigDefaultsComputeLimitsWhenUnset(t *testing.T) {
cfg, err := loadConfigForTest(t, map[string]string{
"EMULATOR_SECRET_KEY": testKeyHex(1),
"EMULATOR_ARKD_URL": "http://arkd:7070",
})
require.NoError(t, err)
require.Equal(t, arkade.DefaultComputeLimits(), cfg.ComputeLimits)
}
56 changes: 56 additions & 0 deletions pkg/arkade/compute_limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package arkade

import "fmt"

// ComputeLimits maps an opcode to the maximum number of times it may execute
// during a single input's script evaluation. Opcodes absent from the map are
// unlimited, matching tapscript's lack of an op-count limit for the cheap
// opcodes whose per-call cost is negligible.
type ComputeLimits map[byte]int

// Validate reports whether every configured limit is non-negative.
func (c ComputeLimits) Validate() error {
for op, limit := range c {
if limit < 0 {
return fmt.Errorf("compute limit for %s is negative: %d",
opcodeArray[op].name, limit)
}
}
return nil
}

// DefaultComputeLimits returns the canonical per-input execution caps for the
// heavy opcodes. It returns a fresh map so callers may modify their copy
// without affecting the engine default.
func DefaultComputeLimits() ComputeLimits {
// Aggregate-cost note: this table is deliberately a simple per-opcode
// lookup, not a grouped or weighted budget. If every listed opcode is pushed
// to its independent cap, the measured heavy-opcode aggregate is ~37 ms per
// input on an Apple M4 Pro. That is looser than the ~24 ms grouped design,
// but keeps the policy easy to read, configure, and reason about for now.
return ComputeLimits{
// Schnorr-class signature verification, ~84 µs each.
OP_CHECKSIG: 50,
OP_CHECKSIGVERIFY: 50,
OP_CHECKSIGADD: 50,
OP_CHECKSIGFROMSTACK: 50,
// Elliptic-curve point operations.
OP_ECADD: 1000, // ~3.7 µs
OP_ECMUL: 50, // ~84 µs
OP_ECMULSCALARVERIFY: 50, // ~84 µs
OP_TWEAKVERIFY: 50, // ~84 µs
OP_ECPAIRING: 2, // ~2.04 ms at the 16-pair cap
OP_MODEXP: 64, // ~60 µs at the 64-byte operand cap
}
}

func cloneComputeLimits(c ComputeLimits) ComputeLimits {
if c == nil {
return nil
}
clone := make(ComputeLimits, len(c))
for op, limit := range c {
clone[op] = limit
}
return clone
}
Loading
Loading