Covenants give Kaspa smart-contract-like power without leaving proof-of-work: the script on a UTXO can inspect the spending transaction and reject it if conditions are not met. But a script that only checks *what* the outputs look like is not enough. It also needs to check *how many inputs* the transaction carries — and *how much* the miner fee can eat. Two subtle attack classes exploit the gap between "the outputs look right" and "the transaction is safe," and Kaspa Forge's vault and escrow scripts close both with a pair of invariants verified during the July 2026 security audit.
This article walks through the attacks, the fixes, and the production trade-offs.
The Problem: Two UTXOs, One Output
Every Kaspa covenant entrypoint — initiate, cancel, complete, release, dispute, and the rest — is a Silverscript program that inspects the spending transaction. When you initiate a withdrawal from a Kaspa Safe vault, the script checks that the output goes to the pre-committed dest address and that the fee does not exceed feeBudget. Simple enough.
Now imagine a user who funded their vault in two separate deposits: UTXO A (1000 KAS) and UTXO B (10 KAS), both sitting at the same P2SH address. An attacker compromises the hot key and wants to drain the vault.
Here is what happens without the single-input invariant:
Multi-UTXO siphon: An attacker constructs one transaction with both UTXO A and UTXO B as inputs. The total input is 1010 KAS. The covenant script runs once per input. For input A, it checks that some output to dest is worth at least 1000 − feeBudget. For input B, it checks that some output to dest is worth at least 10 − feeBudget. Both checks see the *same* output — say, 1000 KAS to dest. Both pass. The remaining 10 KAS (the entire value of UTXO B) leaks into the miner fee. The attacker spent the vault, and 10 KAS vanished into a block reward.
The root cause is that Kaspa's script interpreter validates each input independently, but all inputs share the same set of outputs. When two covenant UTXOs join a transaction, each one's check is satisfied by the same output — and the smaller UTXO's value has nowhere to go except the fee.
This is not a theoretical edge case. Any time a user makes multiple deposits to a vault address, there are multiple UTXOs at that address. A compromised hot key could combine them.
The Second Attack: Top-Up Spending
A related vector does not even need two covenant UTXOs. The attacker takes *one* vault UTXO and adds their *own* non-covenant UTXO as a second input. The vault script sees its input value, checks the output, and passes. But the extra input's value is now part of the transaction — it can be redirected to any output the attacker wants, or absorbed into fees. The attacker essentially "tops up" the transaction with their own funds to manipulate the spending structure, and the covenant script has no idea an extra input exists.
The Fix: require(tx.inputs.length == 1)
Both attacks vanish with a single line at the top of every entrypoint:
require(tx.inputs.length == 1);
If the transaction has more than one input, the script rejects it immediately — before any signature check, before any output inspection. Each covenant UTXO must be spent in isolation, in its own transaction, with its own outputs. There is no second input to leak value into, no way to top up, no shared output to exploit.
The Kaspa wiki's developer knowledge base explains that transaction fees in Kaspa are calculated as the difference between total input value and total output value. When a transaction has only one input, that difference is trivially auditable: fee = input_value − sum(outputs). With multiple inputs, the fee accounting becomes entangled across UTXOs that may carry different script rules — exactly the gap these attacks exploit.
The single-input check collapses that complexity to zero. One input, one output (or a small fixed set), one set of script constraints.
The FeeBudget Griefing Cap
The single-input invariant solves structural leakage. But there is another way to burn a vault's balance: through the *fee itself*.
Several Kaspa Forge entrypoints are keyless — they require no signature at all. complete() (the path that finalizes a withdrawal after the delay) and inheritAuto() (the dead-man-switch inheritance path) are both broadcastable by anyone. The Safe watcher broadcasts them automatically when conditions are met. Any node on the network could technically relay them.
Without a fee cap, whoever constructs the keyless transaction decides the miner fee. A malicious watcher — or anyone monitoring the mempool — could set the fee to the entire UTXO value. The vault script would see fee ≤ feeBudget (if feeBudget was set to the UTXO value at vault creation), pass the check, and the entire balance would go to miners. The keyless paths would still "work" — they would send zero to the intended recipient and everything to the mining pool.
Kaspa Forge's fix, verified in the July 11 audit:
int constant MAX_FEE_BUDGET = 10_000_000; // 0.1 KAS
require(feeBudget > 0 && feeBudget <= MAX_FEE_BUDGET);
This assertion appears in 6 of 7 vault entrypoints and all 10 escrow entrypoints. The hard ceiling of 0.1 KAS (10 million sompi) means that even if an attacker constructs a keyless transaction, they can burn at most 0.1 KAS in fees — a nuisance, not a drain.
The fee budget is a constructor parameter set when the vault is created. The off-chain bindings in Kaspa Forge gate the acceptable range to [1_000_000, 10_000_000] sompi (0.01–0.1 KAS), giving the user a sensible default while the on-chain script enforces the absolute ceiling.
The One Exception: Migrate
The migrate path is the only entrypoint in either script that does not enforce the feeBudget cap. This is intentional.
Migrate requires both the hot key and the alarm key — two independent private keys that the owner should be storing separately. With both signatures, the owner has full authority to define any outputs and any fee. The two-signature requirement is the security boundary here, not the fee cap. Capping the fee on migrate would only restrict the owner during an emergency key rotation or contract upgrade.
The trade-off is explicit: compromise of *both* keys means instant loss, with no delay, no alarm cancellation, and no fee cap. This is documented in the vault architecture as a deliberate design choice — the separation of the two keys is the red line, and users must maintain it.
How Kaspa Forge Implements and Verifies This
Both invariants live in the Silverscript source files — vault.sil (7 entrypoints) and escrow.sil (10 entrypoints) — which are compiled into the WASM core at build time via include_str!. The same source files are used by the browser-side WASM module (for building transactions client-side) and by the offline CLI tool vaultctl (for recovery without any service).
Verification happens at multiple levels:
- Self-tests in the VM. The vault contract carries 18 self-tests; the escrow contract carries 52. Each test exercises a spending path with specific inputs and expected outcomes, including multi-input rejection. These run against the Kaspa node's script interpreter — the same interpreter that validates on-chain.
escrowctl multi-utxoproof. The offlineescrowctltool (part of the Kaspa Forge spike toolkit) includes a dedicated multi-UTXO test that constructs transactions with two covenant inputs and verifies that the script rejects them. This was the basis for the July 10 audit confirmation.
- Off-chain binding guards. The Rust bindings (
bindings.rs) that bridge the WASM core to both the browser and the server reject invalid parameters before the transaction is ever built:delay <= 0triggers a refusal, an heir address withoutinherit_delay > 0is blocked, and fee budget values outside the allowed range are refused at the API boundary.
- Compute budgets. Each entrypoint declares a compute budget (
COMPUTE_BUDGET = 20for normal paths,MIGRATE_BUDGET = 30for the two-signature path). This is a separate layer that caps script execution cost, preventing denial-of-service through overly complex scripts.
The key architectural point is that these checks are in the on-chain script, not in the server or the browser. A user recovering funds through vaultctl against any Kaspa v2+ node gets the same protection. The server never holds keys and cannot bypass script constraints.
Trade-offs and Current Boundaries
The single-input invariant is not free. It introduces real constraints:
No batch withdrawals. If a user funded their vault in 10 deposits, initiating a full withdrawal requires 10 separate initiate transactions — one per UTXO. Kaspa Safe's UI (manage.html) handles this with a withdrawAll function that iterates UTXOs and builds a separate transaction for each, sending them all to the same unvaulting address. The watcher then completes each one independently after the delay. This costs more in network fees than a single batched transaction would, but the cost is bounded by the feeBudget cap per UTXO.
No multi-covenant composability. You cannot atomically spend a vault UTXO and an escrow UTXO in the same transaction. Each covenant type lives in its own spending universe. This is a conscious limitation — composability would introduce cross-script validation complexity that the current Silverscript model does not support.
Dust deposits are expensive to recover. A UTXO worth 0.05 KAS at a vault address costs nearly as much to withdraw (in fees and transaction overhead) as it contains. The feeBudget cap helps by bounding the fee, but the fixed overhead of each transaction still applies. There is no "sweep" shortcut that bypasses the single-input rule.
The migrate escape hatch has its own risk surface. Requiring two signatures is strong security — but it also means the owner must maintain two independent keys indefinitely. Loss of the alarm key (or the hot key) without a backup means the migrate path is permanently locked. The vault is still functional through the normal withdrawal flow and inheritance, but the instant-move capability is gone.
If you want to see how the single-input invariant and feeBudget cap work in a live contract, Kaspa Safe's vault lets you create one in your browser — the script, the keys, and the transaction builders all run client-side. No funds leave your control. Try Kaspa Safe →
The invariants described here are not unique to Kaspa Forge — they are general principles for covenant design on any UTXO chain. But Kaspa's blockDAG and 10-blocks-per-second confirmation rate make the attack window particularly narrow, and the consequences of a leak particularly fast. A single-input check costs one line of code. The alternative is trusting every multi-input transaction construction to be perfect — which, in practice, means trusting the attacker not to find the gap.
FAQ
What is the single-input invariant in a Kaspa covenant?
A script-level rule — require(tx.inputs.length == 1) — that forces every spending transaction to consume exactly one UTXO from the contract. It prevents an attacker from combining multiple covenant UTXOs in a single transaction and siphoning value through the fee.
What is a multi-UTXO siphon attack?
When two UTXOs at the same covenant address are spent together, the script checks each input's conditions independently against shared outputs. The smaller UTXO's entire value can leak into the miner fee while both checks still pass.
What is the feeBudget griefing cap?
A covenant assertion — require(feeBudget > 0 && feeBudget <= 10_000_000) — that limits the maximum miner fee any keyless spending path can authorize to 0.1 KAS. Without it, anyone who can broadcast a keyless path could burn a vault's balance as a fee.
Does the single-input invariant apply to all Kaspa Forge entrypoints?
Yes. All 17 entrypoints — 7 in Kaspa Safe (vault.sil) and 10 in Kaspa Escrow (escrow.sil) — begin with require(tx.inputs.length == 1).
Why is the migrate path exempt from the feeBudget cap?
Migrate requires both the hot key and the alarm key. With two signatures the owner has full control over outputs and fees — capping the fee would only restrict the legitimate owner during an emergency rotation or upgrade.
Can you still batch multiple vault withdrawals?
No, and that is intentional. Each vault UTXO must be withdrawn in its own transaction. Kaspa Safe's UI handles this by building a separate initiate transaction per UTXO in withdrawAll.
Put your KAS where theft can be cancelled
A covenant vault on Kaspa mainnet: your keys, your rules, our tooling. Free on-chain, forever.
Create a vault
