Opcode count compute limits#91
Conversation
There was a problem hiding this comment.
Code Review — Opcode Count Compute Limits
Good design. The flat per-opcode count table is simpler and more appropriate for Arkade than the BIP-342 witness-size-proportional sigops budget. The implementation is clean, well-tested, and the config-override path is solid. A few issues to address:
🔴 MEDIUM — Shared mutable map in defaultComputeLimits (pkg/arkade/compute_limits.go:47, pkg/arkade/engine.go:835)
NewEngine assigns the shared defaultComputeLimits map directly to vm.limits:
var defaultComputeLimits = DefaultComputeLimits() // one map for the process
// engine.go:835
limits: defaultComputeLimits, // every engine gets the SAME map referenceComputeLimits is map[byte]int — a reference type. Today no code writes through vm.limits[op], so this is safe. But it's fragile: a future change that does vm.limits[op] = X on any engine would silently corrupt every engine's limits for the rest of the process lifetime.
Fix: Either:
- Copy the map in
NewEngine(safe, tiny cost — 10 entries), or - Keep the shared reference but add a prominent
// READ-ONLY — do not mutatecomment on both the var and the field, and add a test that verifiesdefaultComputeLimitsis not mutated after engine creation.
Copying is the safer choice for protocol-critical code.
🟡 LOW — WithComputeLimits replaces the entire map (pkg/arkade/script.go:40-43)
func WithComputeLimits(c ComputeLimits) ExecuteOption {
return func(engine *Engine) {
engine.limits = c
}
}If a caller passes a partial map (e.g., ComputeLimits{OP_ECPAIRING: 8}), all other heavy opcodes become unlimited. The config parser handles this correctly by starting from DefaultComputeLimits() and overriding, but a direct API caller of WithComputeLimits could easily make this mistake.
Fix: Add a godoc warning:
// WithComputeLimits overrides the per-input opcode-execution compute brake for
// this execution. c replaces the default limits entirely — opcodes absent from
// c become unlimited. Callers that want to override a subset should start from
// DefaultComputeLimits() and modify the returned map.🟡 LOW — ErrScriptTooBig reuse for semantically different error (pkg/arkade/engine.go:340)
The compute-limit exhaustion reuses ErrScriptTooBig:
return scriptError(txscript.ErrScriptTooBig,
fmt.Sprintf("opcode %s exceeded execution limit of %d", ...))This is the same error code used at engine.go:842 for "script size exceeds max allowed size". Callers that match on ErrScriptTooBig will conflate "script too large" with "opcode executed too many times." The descriptive string distinguishes them, but error-code consumers won't see the string.
Not blocking, but consider whether a dedicated ErrComputeLimitExceeded code would be cleaner for downstream error handling.
🟢 INFO — Observations (no action needed)
-
Dead-branch skip is correct —
chargeOpcodeis called after the!vm.isBranchExecuting()early return inexecuteOpcode, so unexecuted-branch opcodes are never charged. Confirmed byTestChargeOpcodeIgnoresDeadBranch. -
Zero limit —
Validate()acceptslimit=0, meaning an admin can disable an opcode entirely via config (OP_ECPAIRING=0). This is a reasonable safety valve. Tested implicitly viaTestComputeLimitPairingTripsBeforePerCallCap(limit=2 trips at 3rd call, so limit=0 would trip at 1st). -
No cross-repo breakage —
solver,bancod, and other consumers only use opcode constants,NewPacket, andArkadeScriptHashfrom the emulator package. None callscript.Executeorapplication.Newdirectly. Theintrospector-reviewrepo references an older module path and isn't affected. -
Test coverage is good — limit enforcement, dead-branch exemption, override, no-taproot-context bypass, pairing integration, sig counting, and config parsing are all tested. The config tests cover empty/single/multi/unknown/negative/malformed cases.
-
BIP-342 sigops removal — Removing the witness-size-proportional budget is correct for Arkade. The L1 budget models block-weight economics that don't apply here. The new flat-50-per-sig limit is more predictable and sufficient for realistic multisig patterns.
⚠️ Protocol-Critical — Human Review Required
This PR modifies the script VM execution semantics (opcode limiting, sigops budget removal). While the changes are a security improvement (bounding worst-case CPU), they are protocol-critical — they change what scripts are valid. Requires human sign-off before merge.
tl;dr: Fix the shared-map reference (copy in NewEngine), improve WithComputeLimits godoc, and optionally add a dedicated error code. Otherwise solid work. Requesting changes for the shared-map issue as it's a latent correctness risk in protocol-critical code.
There was a problem hiding this comment.
Code Review — Opcode Count Compute Limits
Good design. The flat per-opcode count table is simpler and more appropriate for Arkade than the BIP-342 witness-size-proportional sigops budget. The implementation is clean, well-tested, and the config-override path is solid. A few issues to address:
🔴 MEDIUM — Shared mutable map in defaultComputeLimits (pkg/arkade/compute_limits.go:47, pkg/arkade/engine.go:835)
NewEngine assigns the shared defaultComputeLimits map directly to vm.limits:
var defaultComputeLimits = DefaultComputeLimits() // one map for the process
// engine.go:835
limits: defaultComputeLimits, // every engine gets the SAME map referenceComputeLimits is map[byte]int — a reference type. Today no code writes through vm.limits[op], so this is safe. But it's fragile: a future change that does vm.limits[op] = X on any engine would silently corrupt every engine's limits for the rest of the process lifetime.
Fix: Either:
- Copy the map in
NewEngine(safe, tiny cost — 10 entries), or - Keep the shared reference but add a prominent
// READ-ONLY — do not mutatecomment on both the var and the field, and add a test that verifiesdefaultComputeLimitsis not mutated after engine creation.
Copying is the safer choice for protocol-critical code.
🟡 LOW — WithComputeLimits replaces the entire map (pkg/arkade/script.go:40-43)
func WithComputeLimits(c ComputeLimits) ExecuteOption {
return func(engine *Engine) {
engine.limits = c
}
}If a caller passes a partial map (e.g., ComputeLimits{OP_ECPAIRING: 8}), all other heavy opcodes become unlimited. The config parser handles this correctly by starting from DefaultComputeLimits() and overriding, but a direct API caller of WithComputeLimits could easily make this mistake.
Fix: Add a godoc warning:
// WithComputeLimits overrides the per-input opcode-execution compute brake for
// this execution. c replaces the default limits entirely — opcodes absent from
// c become unlimited. Callers that want to override a subset should start from
// DefaultComputeLimits() and modify the returned map.🟡 LOW — ErrScriptTooBig reuse for semantically different error (pkg/arkade/engine.go:340)
The compute-limit exhaustion reuses ErrScriptTooBig:
return scriptError(txscript.ErrScriptTooBig,
fmt.Sprintf("opcode %s exceeded execution limit of %d", ...))This is the same error code used at engine.go:842 for "script size exceeds max allowed size". Callers that match on ErrScriptTooBig will conflate "script too large" with "opcode executed too many times." The descriptive string distinguishes them, but error-code consumers won't see the string.
Not blocking, but consider whether a dedicated ErrComputeLimitExceeded code would be cleaner for downstream error handling.
🟢 INFO — Observations (no action needed)
-
Dead-branch skip is correct —
chargeOpcodeis called after the!vm.isBranchExecuting()early return inexecuteOpcode, so unexecuted-branch opcodes are never charged. Confirmed byTestChargeOpcodeIgnoresDeadBranch. -
Zero limit —
Validate()acceptslimit=0, meaning an admin can disable an opcode entirely via config (OP_ECPAIRING=0). This is a reasonable safety valve. Tested implicitly viaTestComputeLimitPairingTripsBeforePerCallCap(limit=2 trips at 3rd call, so limit=0 would trip at 1st). -
No cross-repo breakage —
solver,bancod, and other consumers only use opcode constants,NewPacket, andArkadeScriptHashfrom the emulator package. None callscript.Executeorapplication.Newdirectly. Theintrospector-reviewrepo references an older module path and isn't affected. -
Test coverage is good — limit enforcement, dead-branch exemption, override, no-taproot-context bypass, pairing integration, sig counting, and config parsing are all tested. The config tests cover empty/single/multi/unknown/negative/malformed cases.
-
BIP-342 sigops removal — Removing the witness-size-proportional budget is correct for Arkade. The L1 budget models block-weight economics that don't apply here. The new flat-50-per-sig limit is more predictable and sufficient for realistic multisig patterns.
⚠️ Protocol-Critical — Human Review Required
This PR modifies the script VM execution semantics (opcode limiting, sigops budget removal). While the changes are a security improvement (bounding worst-case CPU), they are protocol-critical — they change what scripts are valid. Requires human sign-off before merge.
tl;dr: Fix the shared-map reference (copy in NewEngine), improve WithComputeLimits godoc, and optionally add a dedicated error code. Otherwise solid work. Requesting changes for the shared-map issue as it's a latent correctness risk in protocol-critical code.
|
Note on compute-limit granularity: The current implementation intentionally uses a very simple Measured on an Apple M4 Pro, that implemented worst case is roughly 37 ms per input when all configured heavy-opcode counts are pushed to their limits. A grouped-counter model would bring the same calculation closer to ~24 ms, but the current bound is still far below the multi-second unbounded cases this PR is meant to prevent. This is a deliberate simplicity tradeoff for now: the table is easy to read, configure, validate, and reason about. If we later need a tighter aggregate CPU bound, we can move from per-opcode counters to grouped counters or a weighted budget without changing the basic charging hook in the engine.
|
There was a problem hiding this comment.
Follow-up Review — New Commits 74275cf2, 0cec6efc
All three issues from the previous review are resolved:
✅ Shared mutable map (was 🔴 MEDIUM)
NewEngine now calls DefaultComputeLimits() for a fresh copy each time. The defaultComputeLimits package-level var is gone. cloneComputeLimits helper added for WithExactComputeLimits. TestNewEngineDefaultLimitsAreIndependent confirms engines don't share state. Clean fix.
✅ WithComputeLimits partial-map footgun (was 🟡 LOW)
Split into two functions:
WithComputeLimits(c)— mergescon top ofDefaultComputeLimits(), nil is a no-op. Safe for partial overrides.WithExactComputeLimits(c)— exact replacement with clone. Used by the config path (which already resolved from defaults).
Godoc on both is clear. TestWithComputeLimitsMergesWithDefaults and TestWithExactComputeLimitsCanRemoveDefaultLimit cover the distinction. TestWithComputeLimitsNilKeepsDefaultLimits covers the nil edge case.
✅ Config refinements
- Empty value (
OP_ECADD=) now deletes the limit (makes it unlimited) — tested byTestParseComputeLimitsEmptyValueRemovesLimit. - Empty pair and trailing comma now error — tested.
- Aggregate-cost comment added to
DefaultComputeLimits(). - Call sites correctly use
WithExactComputeLimitssince the config path already merges with defaults.
Data flow verification
parseComputeLimits("") → returns full DefaultComputeLimits() (never nil) → stored in Config.ComputeLimits → passed to application.New → s.computeLimits → WithExactComputeLimits(s.computeLimits). Correct at every step.
There was a problem hiding this comment.
Follow-up Review — New Commits 74275cf2, 0cec6efc
All three issues from the previous review are resolved:
✅ Shared mutable map (was 🔴 MEDIUM)
NewEngine now calls DefaultComputeLimits() for a fresh copy each time. The defaultComputeLimits package-level var is gone. cloneComputeLimits helper added for WithExactComputeLimits. TestNewEngineDefaultLimitsAreIndependent confirms engines don't share state. Clean fix.
✅ WithComputeLimits partial-map footgun (was 🟡 LOW)
Split into two functions:
WithComputeLimits(c)— mergescon top ofDefaultComputeLimits(), nil is a no-op. Safe for partial overrides.WithExactComputeLimits(c)— exact replacement with clone. Used by the config path (which already resolved from defaults).
Godoc on both is clear. TestWithComputeLimitsMergesWithDefaults and TestWithExactComputeLimitsCanRemoveDefaultLimit cover the distinction. TestWithComputeLimitsNilKeepsDefaultLimits covers the nil edge case.
✅ Config refinements
- Empty value (
OP_ECADD=) now deletes the limit (makes it unlimited) — tested byTestParseComputeLimitsEmptyValueRemovesLimit. - Empty pair and trailing comma now error — tested.
- Aggregate-cost comment added to
DefaultComputeLimits(). - Call sites correctly use
WithExactComputeLimitssince the config path already merges with defaults.
Data flow verification
parseComputeLimits("") → returns full DefaultComputeLimits() (never nil) → stored in Config.ComputeLimits → passed to application.New → s.computeLimits → WithExactComputeLimits(s.computeLimits). Correct at every step.
chris-ricketts
left a comment
There was a problem hiding this comment.
Wondering if the opCounts should be exposed in the StepInfo, as well as an option to count but not error on limit exceeded.
E.g. I could imagine a scenario where someone is building a script and they want to count the limited opcodes in the non-dead paths for debugging/optimisation.
However, this could be in a future PR if deemed necessary.
Otherwise looks solid - LGTM
Let's do this in a seperate PR should the need arise. In the end a debugging tool that would do this besides other things such as stepping through the execution etc would be ideal. |
Summary
Adds a per-input execution-count brake for the VM's heavy opcodes and removes the BIP-342 sigops budget, replacing both with one simple lookup table. This bounds worst-case single-input CPU and unblocks the heavy-opcode PRs (#79 EC ops, #80 OP_MODEXP), which add opcodes that are otherwise charged nothing and only limited by the 10 KB script-size cap.
Motivation
The VM enforces script size and stack size but has no op-count limit. BIP-342 dropped it deliberately, which is fine for cheap opcodes.
Several Arkade opcodes (
OP_CHECKSIGFROMSTACK,OP_ECADD,OP_ECMUL,OP_ECPAIRING,OP_ECMULSCALARVERIFY,OP_TWEAKVERIFY,OP_MODEXP) cost orders of magnitude more per call, so an adversarial 10 KB script could drive multi-second CPU per input. See #81.What changed
Added
ComputeLimitslookup table (pkg/arkade/compute_limits.go):type ComputeLimits map[byte]intDefaultComputeLimits()returns a fresh copyAdded engine charging (
engine.go):chargeOpcodecall inexecuteOpcodetxscript.ErrScriptTooBig, naming the opcode and limitRemoved the BIP-342 sigops budget:
sigOpsBudget,tallysigOp,sigOpsDeltatallysigOpcalls in the sig handlersAdded admin override (
internal/config):EMULATOR_COMPUTE_LIMITS="OP_ECPAIRING=8,OP_MODEXP=128"WithComputeLimitsinto the threescript.Executecall sitesRetained existing per-call caps:
maxECPairingCount = 16maxModexpOperandLen = 64Default limits
OP_CHECKSIG,OP_CHECKSIGVERIFY,OP_CHECKSIGADD,OP_CHECKSIGFROMSTACKOP_ECADDOP_ECMUL,OP_ECMULSCALARVERIFY,OP_TWEAKVERIFYOP_ECPAIRINGOP_MODEXPBehavior change
Dropping the sigops budget removes the BIP-342 empty-signature exemption: an empty-signature
OP_CHECKSIG/OP_CHECKSIGADDin the unselected-branch multisig pattern now counts toward the opcode's limit.This only over-counts cheap non-verifying calls. It never permits extra real verifications, and 50 is generous for realistic multisig.
Closing #81