Skip to content

fix: address locktime findings from the 0.14 audit - #455

Draft
rkalis wants to merge 3 commits into
nextfrom
fix/locktime-audit-findings
Draft

rkalis wants to merge 3 commits into
nextfrom
fix/locktime-audit-findings

Conversation

@rkalis

@rkalis rkalis commented Sep 28, 2026

Copy link
Copy Markdown
Member

Addresses the remaining (locktime-related) findings from the 0.14 audit.

Compiler

  • this.age durations: time units (e.g. require(this.age >= 30 days)) were compared as a number of blocks. Durations are now encoded as BIP68 512-second chunks with the type flag, rounded up so the timelock is never shorter than written. This also works for global constants holding a duration (int literals now track whether they were written with a time unit, including through constant folding).
  • Compile-time this.age values: values that are not a valid BIP68 relative timelock are now a compile error (InvalidTimelockError). Valid values are a number of blocks (0–65535), a duration of up to 33,553,920 seconds (~388 days), or an already-encoded BIP68 value. A time unit inside a larger this.age expression (e.g. period + 1 days) is also rejected, since it can't be encoded at compile time.
  • Runtime this.age values (e.g. a constructor parameter) get a 10-byte runtime check before OP_CHECKSEQUENCEVERIFY: (x >> 16) * ((x >> 16) - 64) == 0, i.e. only BIP68 blocks or 512-second chunks. It has its own require message for debugging. There is no compiler option to disable it.
  • tx.time literals: values outside 0..4294967295, and durations below 500,000,000 (e.g. tx.time >= 2 hours, the bug in the docs example), are now a compile error.
  • Locktime guard: since every this.age check now enforces a non-final sequence number, any require(this.age >= ...) counts as covering tx.locktime. Runtime this.age values no longer cause an extra injected guard.

SDK

  • Relative timelock option: sequence accepts { blocks } or { seconds }, encoded with encodeBip68 (which now rounds seconds up, matching the compiler).
  • Raw sequence validation: a raw sequence that enables a relative timelock but sets bits BIP68 ignores is rejected. Values with the disable flag (including the default 0xfffffffe) pass unchanged.
  • Failure reason: FailedRequireError now shows libauth's reason when a require fails for another reason than a false condition, e.g. a missing sequence number, an incompatible locktime type, or an invalid VM number.
  • Tests: replaced the it.todo('test sequence numbers') with mocknet end-to-end tests. The mock network doesn't check the UTXO's age, so these are skipped on chipnet.

Docs

  • globals.md: this.age / tx.time sections describe the encoding, limits and SDK usage. The "not supported to use this.age as second chunks" sentence is removed.
  • contracts.md: the constants example now uses this.age >= EXTENDED_TIMEOUT.
  • transaction-builder.md: documents the InputOptions changes.
  • Release notes updated. The covenants guide's this.age >= 180 days example is now correct without changes.

Bytecode impact

  • Contracts using time units with this.age get a different (correct) constant.
  • Contracts with a runtime this.age value get the 10-byte check.
  • Contracts that combine a runtime this.age check with tx.locktime lose the injected guard.
  • Contracts with block-count this.age literals or with tx.time are unchanged.

🤖 Generated with Claude Code

- Encode this.age durations with time units as BIP68 512-second chunks (rounded up)
- Reject compile-time this.age values that are not a valid BIP68 relative timelock
- Check runtime this.age values when the contract is spent
- Reject tx.time values outside the 32-bit locktime range, or written as a duration
- Accept { blocks } or { seconds } as the sequence input option, and reject raw sequence numbers with ignored BIP68 bits
- Show libauth's reason when a require statement fails for another reason than a false condition
- Add end-to-end sequence number tests and update the timelock docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cashscript Ready Ready Preview Sep 28, 2026 8:50pm UTC

Request Review

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.58065% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.86%. Comparing base (2a2e050) to head (392d481).

Files with missing lines Patch % Lines
...cashc/src/semantic/FoldGlobalConstantsTraversal.ts 92.30% 2 Missing ⚠️
...es/cashc/src/semantic/ResolveTimelocksTraversal.ts 98.03% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             next     #455      +/-   ##
==========================================
+ Coverage   89.34%   89.86%   +0.51%     
==========================================
  Files          61       62       +1     
  Lines        5048     5148     +100     
  Branches      942      977      +35     
==========================================
+ Hits         4510     4626     +116     
+ Misses        413      407       -6     
+ Partials      125      115      -10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A this.age operand that refers to a global int constant was validated at
compile time, but the validated literal still referred to its constant, so
it was lowered to a constant function call and code generation added the
runtime check as well. Block-count constants such as `this.age >= WAIT_BLOCKS`
now compile to a plain push, the same as a literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mr-zwets

Copy link
Copy Markdown
Member

On behalf of Mathieu G. (mr-zwets), drafted with Claude Opus 5.5.

Pushed a small follow-up, 61c1982. A this.age operand that is a global int constant holding a block count got the runtime BIP68 check even though its value is known at compile time: this.age >= WAIT_BLOCKS compiled to 9000 <9-opcode check> OP_CHECKSEQUENCEVERIFY, while this.age >= 144 compiled without it. Local and imported constants both hit it.

Cause: ResolveTimelocksTraversal validated the constant but returned the same literal, which still carries .constant. LowerGlobalConstantsTraversal then turns it into a constant function call, and GenerateTargetTraversal sees a non-literal and adds the check. The duration path already returns a fresh literal, so this does the same for block counts.

Added an afterConstantBlocks case to the relative_timelocks fixture, which now compiles to a plain push like a literal. cashc tests pass (604).

With this, the locktime findings from the 0.14 audit look addressed to us.

…ive timelocks

The time-unit flag was kept through every folded operation, so a global
constant such as `1 days / 10 minutes * 7` (a number of blocks) was encoded
as a duration in seconds, and `4194304 + (1 days + 511) / 512` (already
encoded chunks) was encoded again. The flag now follows the units: sums and
multiples of durations are durations, a ratio of two durations is a plain
number, and a duration combined with a plain number is rejected in this.age,
and in tx.time below 500,000,000.

A `sequence` option without `blocks` or `seconds` (e.g. `{}` from plain
JavaScript) was encoded as a final sequence number, which silently disabled
the transaction's locktime. It is now rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mr-zwets

mr-zwets commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

On behalf of Mathieu G. (mr-zwets), drafted with Claude Opus 5.5.

Pushed another follow-up, 392d481, for two issues from our pre-release audit:

  • The time-unit flag survived every folded operation. int constant WEEK_IN_BLOCKS = 7 * (1 days / 10 minutes); compiled to 1008 blocks on next.6, but with this PR to two 512-second chunks, about 17 minutes. The flag now follows the units: sums, differences and multiples of durations stay durations, a ratio of two durations is a plain number, and a duration mixed with a plain number (e.g. 1 days + 511) is rejected in this.age, and in tx.time below 500,000,000. A date plus a duration still works as a timestamp.
  • A sequence object without blocks or seconds (e.g. {} from plain JavaScript) was encoded as a final sequence, silently disabling the locktime. It is now rejected.

@mr-zwets

Copy link
Copy Markdown
Member

On behalf of Mathieu G. (mr-zwets), drafted with Claude Opus 5.5.

One remaining area from our audit: #455 encodes time units when the this.age / tx.time operand is a literal or a global constant, but a duration that travels any other way is still a plain number of seconds:

  • Local variables: int d = 10 minutes; require(this.age >= d); is 600 blocks (about 4 days). int d = 30 days; gets the runtime check, which rejects 2,592,000, so the path can never be spent. int d = TIMEOUT; (a copy of a duration constant) behaves the same. int d = 2 hours; require(tx.time >= d); is block height 7200.
  • Function results and arguments: this.age >= f() where f returns 1 days, or a duration passed through a local into a function.
  • Casts: int(bytes(1 hours)) in this.age is 3600 blocks.
  • Dates: date literals are plain numbers, so date(a) - date(b) in this.age is a number of blocks, and a date before 1985-11-05 in tx.time is read as a block height.

Tradeoffs. Time units have real potential: durations read far better than raw numbers, #455 now encodes them correctly as BIP68 where it can, and the compiler could catch mixing seconds with block counts, which is an easy mistake. But treating them specially raises questions that go beyond timelocks:

  • Conversion: a duration is seconds, a this.age operand is blocks or 512-second chunks, and a tx.time operand is a block height or a timestamp.
  • Combinations: a ratio of two durations is a plain count, a duration plus a plain number has no clear unit, a date plus a duration is a timestamp, and a runtime value plus or times a duration (startTime + 30 days) has to stay valid in tx.time.
  • Reach: a unit can travel through locals, loops, function arguments and results, and casts.

Handling all of that is effectively a unit type in the type system. The alternatives are to keep units as plain seconds everywhere except where they are encoded, which is simpler but leaves the cases above as documented footguns, or to restrict where time-unit literals may appear.

392d481 only applies these rules to folded global constants, where #455 already tracked units; it does not follow units any further.

This branch was successfully deployed

1 active deployment
Preview — 392d4818 Deployed Sep 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants