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
- An operator or anyone calls
withdrawalTimeLockTransaction to build a withdrawal from a time-locked covenant output.
- The function returns a PSBT with
sequence set to the timelock value but transaction version = 1.
- The caller broadcasts the transaction immediately.
- Because tx version is 1,
OP_CHECKSEQUENCEVERIFY in the witness script is a no-op — the sequence field is ignored.
- The timelock is completely bypassed. The BTC is immediately spendable without waiting for the lock height/CSV delay to elapse.
- 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))
Summary
Missing
psbt.setVersion(2)inwithdrawalTimeLockTransactioncompletely bypasses the OP_CHECKSEQUENCEVERIFY (CSV) timelock covenant oncovenantV1locking 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, functionwithdrawalTimeLockTransaction(lines 164-193):The function sets
sequence: timelockon the input (line 174), which is consumed byOP_CHECKSEQUENCEVERIFYin the witness script to enforce the timelock. However,OP_CHECKSEQUENCEVERIFYis a no-op on transaction version 1.bitcoinjs-lib'sPsbtdefaults to transaction version 1. The function never callspsbt.setVersion(2).This is confirmed by the sibling function
continueTimelockLockingTransactioninsrc/slashable/locking/index.ts:847-848, which correctly does callpsbt.setVersion(2)before adding the timelock input:How it can be exploited
withdrawalTimeLockTransactionto build a withdrawal from a time-locked covenant output.sequenceset to the timelock value but transaction version = 1.OP_CHECKSEQUENCEVERIFYin the witness script is a no-op — thesequencefield is ignored.Impact: fund theft — any BTC locked under a
covenantV1timelock 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)beforepsbt.addInput(...)inwithdrawalTimeLockTransaction, matching the pattern already used incontinueTimelockLockingTransaction(slashable/locking/index.ts:847-848).References
src/covenantV1/locking.ts:164-193(withdrawalTimeLockTransaction, missingsetVersion(2))src/slashable/locking/index.ts:845-848(continueTimelockLockingTransaction, correctpsbt.setVersion(2)pattern)src/slashable/bridge/index.ts:291(recaptureTransaction, also haspsbt.setVersion(2))src/slashable/locking/index.ts:353(withdrawalTransaction, also haspsbt.setVersion(2))