Skip to content

[Security] covenantV1 withdrawalTimeLockTransaction missing psbt.setVersion(2), bypassing CSV timelock and enabling immediate fund theft #472

Description

@HusseinAdeiza

Summary

Missing psbt.setVersion(2) in withdrawalTimeLockTransaction completely bypasses the OP_CHECKSEQUENCEVERIFY (CSV) timelock covenant on covenantV1 locking scripts. Any withdrawal transaction built through this function can be broadcast immediately — the timelock is ignored because CSV only activates on transaction version 2+, and the PSBT is left at the default version 1. This allows immediate theft of BTC that should remain time-locked.

Severity

Critical (fund theft — timelock covenant entirely bypassed, no conditions required).

Affected component

btc-script-factory (GOATNetwork/btc-script-factory) — the repository that constructs the Bitcoin P2WSH/P2TR locking and spending scripts for the GOAT bridge.

Root cause

src/covenantV1/locking.ts, function withdrawalTimeLockTransaction (lines 164-193):

const psbt = new Psbt({ network });
// ...
psbt.addInput({
    hash: lockingTransaction.getId(),
    index: outputIndex,
    witnessUtxo: { ... },
    witnessScript: scripts.lockingScript,
    sequence: timelock       // timelock is SET, but never activated
});
// ...
return { psbt };            // <-- psbt.setVersion(2) is MISSING

The function sets sequence: timelock on the input (line 174), which is consumed by OP_CHECKSEQUENCEVERIFY in the witness script to enforce the timelock. However, OP_CHECKSEQUENCEVERIFY is a no-op on transaction version 1. bitcoinjs-lib's Psbt defaults to transaction version 1. The function never calls psbt.setVersion(2).

This is confirmed by the sibling function continueTimelockLockingTransaction in src/slashable/locking/index.ts:847-848, which correctly does call psbt.setVersion(2) before adding the timelock input:

// Set PSBT version to 2
psbt.setVersion(2);   // <-- present here, absent in withdrawalTimeLockTransaction

How it can be exploited

  1. An operator or anyone calls withdrawalTimeLockTransaction to build a withdrawal from a time-locked covenant output.
  2. The function returns a PSBT with sequence set to the timelock value but transaction version = 1.
  3. The caller broadcasts the transaction immediately.
  4. Because tx version is 1, OP_CHECKSEQUENCEVERIFY in the witness script is a no-op — the sequence field is ignored.
  5. The timelock is completely bypassed. The BTC is immediately spendable without waiting for the lock height/CSV delay to elapse.
  6. An attacker (or the operator themselves) walks away with the timelocked BTC immediately.

Impact: fund theft — any BTC locked under a covenantV1 timelock can be withdrawn immediately, regardless of the intended lock duration. No special conditions are needed; any caller of this function can trigger the exploit.

Suggested fix

Add psbt.setVersion(2) before psbt.addInput(...) in withdrawalTimeLockTransaction, matching the pattern already used in continueTimelockLockingTransaction (slashable/locking/index.ts:847-848).

References

  • src/covenantV1/locking.ts:164-193 (withdrawalTimeLockTransaction, missing setVersion(2))
  • src/slashable/locking/index.ts:845-848 (continueTimelockLockingTransaction, correct psbt.setVersion(2) pattern)
  • src/slashable/bridge/index.ts:291 (recaptureTransaction, also has psbt.setVersion(2))
  • src/slashable/locking/index.ts:353 (withdrawalTransaction, also has psbt.setVersion(2))

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions