Skip to content

fix(htlc): evaluate claim/reclaim deadlines against a deterministic reference time - #2160

Open
AkramBitar wants to merge 1 commit into
mainfrom
fix-1750-htlc-deterministic-deadline
Open

fix(htlc): evaluate claim/reclaim deadlines against a deterministic reference time#2160
AkramBitar wants to merge 1 commit into
mainfrom
fix-1750-htlc-deterministic-deadline

Conversation

@AkramBitar

@AkramBitar AkramBitar commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #1750

The setup

An HTLC is a locked token with a deadline. Alice locks tokens for Bob:

  • Before the deadline → only Bob can take them, and only by revealing a secret.
  • After the deadline → only Alice can take them back.

This is how atomic swaps work: two parties trade tokens without trusting each other. The deadline is what makes it safe.

Example

HTLC (Hash Time-Locked Contract) is a conditional token transfer:
the recipient can receive the tokens by providing the correct secret before a deadline;
otherwise, the sender can recover the tokens after the deadline.

Example:

Alice owns 100 TOKEN
        ↓
Alice locks 100 TOKEN in an HTLC
        ↓
HTLC stores hash(secret) + deadline
        ↓
Bob provides the secret
        ↓
HTLC verifies hash(secret) == stored hash
        ↓
Valid → Bob receives 100 TOKEN

If Bob does not provide the correct secret before the deadline:
        ↓
Alice can recover the 100 TOKEN

The problem

Several peers independently validate each transaction, and each one was checking the deadline against its own clock.

Clocks are never perfectly in sync. So for a spend submitted near the deadline:

  • Peer A's clock says 2:59:59 → "before the deadline, this is Bob's claim" → approves
  • Peer B's clock says 3:00:01 → "after the deadline, only Alice may reclaim" → rejects

Same transaction, opposite verdicts. That breaks endorsement, and worse, it makes the deadline fuzzy in an uncontrolled way: a claim can slip through past the intended cutoff, or a claim and a reclaim can race.

A third clock read lived inside htlc.Verifier, so the signature check and the owner check could even disagree on the same peer. htlc.Verifier already had a ClockFunc hook for exactly this purpose — it had never been wired up.

In shot peers used different local clocks to evaluate the HTLC deadline, so the same transaction could be accepted by one peer and rejected by another.

Example:

Alice owns 100 TOKEN
        ↓
Alice locks 100 TOKEN in an HTLC
        ↓
HTLC stores hash(secret) + deadline (3:00:00)
        ↓
Bob provides the secret near 3:00:00
        ↓
Multiple peers validate the same claim
        ↓
Peer 1: 2:59:59
→ Before deadline → Bob receives 100 TOKEN ✅
        ↓
Peer 2: 3:00:01
→ After deadline → Bob's claim is rejected ❌
        ↓
❌ Same claim, different result because peers use their own clocks

The fix

Stop asking "what time is it?" and start asking "what time does the transaction say it is?"

Every Fabric transaction carries a timestamp in its channel header, and that header is covered by the proposal signature. Every peer reads the identical value, and so does any later re-validation. The claim/reclaim decision becomes pure arithmetic on two signed numbers — no clock, so no disagreement is possible.

All three clock reads now use that one reference time, carried on the context:

Consumer Decides
fabtoken / zkatdlog TransferHTLCValidate claim vs reclaim; deadline sanity check on a new lock
htlc.TypedIdentityDeserializer.DeserializeVerifier whose signature is demanded (via ClockFunc)

Injected at both validation entry points: the token chaincode from stub.GetTxTimestamp(), the FSC endorsement responder from the proposal's channel header.

Why the timestamp is bounded against the local clock

The timestamp is written by the client and the ordering service does not vet it. Trusted outright, it would hand the deadline to the client: back-date a proposal and an expired HTLC stays claimable forever.

So peers still glance at their own clock — not to decide the deadline, but to sanity-check that the claimed time is plausible. Outside the tolerance the request is rejected.

The two jobs are deliberately separated:

  • The verdict (who gets the tokens?) always comes from the signed timestamp, so every peer that accepts a request agrees on it. This is the safety property.
  • Admissibility (is this transaction plausible?) uses the local clock. If peers disagree here, the transaction simply fails to gather enough endorsements and is resubmitted. Nobody's tokens are at risk — a liveness cost, not a safety one.

Choosing the tolerance

The tolerance is exactly the window an adversary can shift a deadline by. Back-date by just under it and an expired HTLC stays claimable that long; post-date and a reclaim becomes available that early. So it is kept short:

  • Default 30s (driver.DefaultValidationTimeSkew). NTP-synced nodes agree to within milliseconds, so this is ample headroom while keeping the window tight.
  • Configurable: per-TMS key validation.maxTimeSkew for the FSC responder, TOKEN_VALIDATION_MAX_TIME_SKEW for the standalone chaincode. That split mirrors the existing validation.limits / TOKEN_VALIDATION_MAX_* precedent, and for the same reason — the chaincode process only ever has public parameters in scope, never a TMS identifier.

Atomic swaps already set their two deadlines hours apart by design, so a bounded 30s is far beneath the protocol's safety margin. The key change is that the fuzziness is now known, bounded, and agreed on by every peer, instead of unbounded and non-deterministic.

Considered and rejected

  • Let the ordering service stamp the time. The block timestamp does not exist when Panurus validates, so this would reshape the endorsement model. And Fabric does not enforce that block timestamps are accurate or monotonic, so it buys a different party's clock, not a correct one.
  • Express deadlines as block heights. Fully deterministic (what Bitcoin does), but wrong here: HTLCs exist for cross-network swaps, and block heights do not translate between chains with different block rates.

Examples

HTLC Deterministic Timestamp Validation

Alice owns 100 TOKEN
        ↓
Alice locks 100 TOKEN in an HTLC
        ↓
HTLC stores:
    hash(secret)
    deadline = 3:00:00
        ↓
Bob provides the secret
        ↓
Transaction contains signed timestamp = 2:59:59
        ↓
Multiple peers validate the same transaction
        ↓
Peer 1
→ Uses transaction time: 2:59:59
→ 2:59:59 < 3:00:00
→ Bob's claim is valid ✅
        ↓
Peer 2
→ Uses the SAME transaction time: 2:59:59
→ 2:59:59 < 3:00:00
→ Bob's claim is valid ✅
        ↓
✅ Both peers reach the same decision
HTLC Timestamp-Cheating Problem and Fix
Alice locks 100 TOKEN in an HTLC for Bob
        ↓
HTLC deadline = 3:00:00
        ↓
Real time = 3:05:00
        ↓
Bob creates a NEW claim transaction
        ↓
Bob tries to backdate it: timestamp = 2:59:59
        ↓
Endorser checks:
Transaction time = 2:59:59
Local clock      = 3:05:00
Difference       = 5 minutes
Allowed skew     = 30 seconds
        ↓
5 minutes > 30 seconds
        ↓
❌ Claim rejected

Normal case:
Transaction time = 2:59:59
Local clock      = 3:00:05
Difference       = 6 seconds < 30 seconds
        ↓
2:59:59 < 3:00:00
        ↓
✅ Bob's claim accepted

Testing

  • The three new validator tests were run against the pre-fix code and fail there, so they pin the behaviour rather than just documenting it.
  • Determinism tests in both drivers: same transaction, reference times either side of the deadline, two simulated peers → identical verdicts.
  • ClockFunc wiring: the deserialized verifier demands the recipient's signature before the deadline and the sender's after, driven by the injected time.
  • Both entry points: reference time reaches the validator; missing/unparsable/implausible timestamps rejected; a tightened tolerance verifiably rejects what the default accepts.
  • New FuzzChannelHeaderTimestampNoPanic over the added wire-format parsing, wired into nightly-fuzz.yml.
  • make checks and make lint clean; race detector clean on all touched packages.

Docs

@AkramBitar AkramBitar added this to the Q3/26 milestone Aug 7, 2026
@AkramBitar AkramBitar added interoperability go Pull requests that update go code security labels Aug 7, 2026
@AkramBitar AkramBitar self-assigned this Aug 7, 2026
@AkramBitar
AkramBitar force-pushed the fix-1750-htlc-deterministic-deadline branch 5 times, most recently from 72310b0 to d53f695 Compare August 7, 2026 14:35
…eference time

Signed-off-by: AkramBitar <akram@il.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Pull requests that update go code interoperability security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTLC claim/reclaim deadline is validated against local time.Now(), making validation non-deterministic

1 participant