I looked at the irreversible-expiry boundary more closely and would suggest the following CKB-native construction for the critical owner-freeze path.
Core correction
For an owner operation that is valid only before a stored deadline, I would not use a caller-selected header_dep as proof of current chain time.
Use a two-phase transition instead, so the time anchor is the actual commitment block of an intermediate Cell:
ACTIVE
-> owner-signed BEGIN_OWNER_OP
-> PENDING_OWNER_OP
-> NORMALIZE -> ACTIVE
-> RECOVER -> CLAIMED
The first transaction does not apply the heartbeat or recipient update directly. It only commits to the requested operation.
PendingOwnerOp {
version
previous_state_hash
previous_deadline_epoch
inactivity_period_epochs
owner_lock_hash
previous_recovery_lock_hash
operation_kind // HEARTBEAT | RECIPIENT_UPDATE
operation_payload_hash
proposed_recovery_lock_hash
state_nonce
}
BEGIN_OWNER_OP must enforce:
owner signature is valid
exactly one successor PENDING_OWNER_OP Cell exists
protected capacity / typed value is conserved exactly
previous policy is copied exactly
previous deadline is copied exactly
requested operation is committed by hash
no protected value is released
no recipient change is applied directly
no deadline extension is applied directly
Commitment-bound time decision
When PENDING_OWNER_OP is later consumed, the script loads the header associated with that exact input Cell:
ckb_load_header(..., input_index, CKB_SOURCE_INPUT)
The corresponding block header must be present in header_deps, but it is not being trusted as an arbitrary historical header selected by the transaction builder. With CKB_SOURCE_INPUT, the script resolves the block in which that input Cell was created.
Use one consensus metric end-to-end. For v1, an absolute epoch-number-with-fraction policy keeps the comparison deterministic:
begin_epoch = epoch of the block that committed PENDING_OWNER_OP
old_deadline = previous_deadline_epoch
timely = epoch_less(begin_epoch, old_deadline)
The epoch comparison must use CKB epoch-fraction semantics, not a raw integer comparison of the encoded field.
Effective state
The semantic result is derived only from the commitment-bound begin_epoch:
if timely and operation_kind == HEARTBEAT:
effective_recovery = previous_recovery
effective_deadline =
epoch_add(begin_epoch, inactivity_period_epochs)
if timely and operation_kind == RECIPIENT_UPDATE:
effective_recovery = proposed_recovery
effective_deadline =
epoch_add(begin_epoch, inactivity_period_epochs)
if not timely:
effective_recovery = previous_recovery
effective_deadline = old_deadline
owner_operation_effect = NONE
A late owner transaction can therefore create no new recovery authority and no new deadline.
NORMALIZE is only a canonicalization step. It must not accept a different policy, value set, deadline, or operation payload. For a late pending operation, normalization into a modified owner state is rejected; recovery proceeds under the previous policy.
The pending Cell itself must remain recoverable, so failure to normalize cannot trap the protected value.
Recovery path
Recovery uses the derived effective policy:
recovery signer = effective_recovery
input since =
absolute epoch threshold
exactly equal to effective_deadline
The lock verifies that the encoded since threshold matches the derived deadline. Consensus then supplies the lower-bound rule:
target_epoch >= effective_deadline
This creates the required asymmetry:
owner-side update:
must have entered the chain before old_deadline
recovery:
cannot enter the chain before effective_deadline
Pre-signed heartbeat test
1. owner signs BEGIN_OWNER_OP before deadline D
2. owner keeps the transaction offline
3. chain passes D
4. owner broadcasts the old transaction
5. PENDING_OWNER_OP is committed after D
6. begin_epoch >= old_deadline
7. owner_operation_effect = NONE
8. previous recovery recipient remains authoritative
9. previous deadline remains authoritative
The signature creation time is irrelevant.
Adding an unrelated old block to header_deps cannot change the result, because the decision uses the header associated with the actual pending input Cell.
Minimum blocking tests
TEST 01
BEGIN committed before D
-> operation is accepted
-> new deadline derives from the actual pending-cell commitment epoch
TEST 02
BEGIN signed before D but committed after D
-> owner operation has no effect
-> old recovery policy remains authoritative
TEST 03
late RECIPIENT_UPDATE
-> proposed recipient never becomes effective
TEST 04
arbitrary stale header added to header_deps
-> cannot substitute for the header associated with the pending input Cell
TEST 05
pending transition changes protected value, previous policy, or previous deadline
-> transaction fails
TEST 06
timely pending Cell is never normalized
-> recovery still becomes possible at the derived effective deadline
TEST 07
same operation is replayed against a later Tranfr state
-> previous_state_hash / state_nonce binding fails
TEST 08
reorganization and reinclusion
-> time anchor follows the canonical commitment block of the live pending Cell
TEST 09
epoch-fraction boundary
-> SDK and lock script derive identical ordering and threshold values
The core invariant is:
Owner authority is determined by when the owner state transition entered the canonical chain, not by when it was signed and not by an arbitrary header chosen by the owner.
One remaining scope boundary
If ordinary owner spends are also meant to become impossible after expiry, they need the same commitment-bound gate.
Otherwise the security claim should be narrower and explicitly state that irreversible expiry freezes heartbeat and recipient-update authority, while ordinary spending follows a different rule.
A single-step owner spend cannot obtain an upper-validity bound from since, because since is a lower-bound precondition.
References
CKB RFC 0022 — Transaction Structure / Header Deps
CKB RFC 0017 — Transaction Since Precondition
Nervos discussion — two-step pattern for an operation valid only before T