Tranfr — Programmable Recovery for CKB

A programmable safety net for CKB self-custody

Applicant: SalmanDev (Developer behind Fiber checkout) Funding requested: $1,600 USD Timeline: 8 weeks Category: CKB Infrastructure / Security / Self-Custody

1. Summary

On CKB today, non-custodial funds answer to exactly one key. Lose it and the money is gone forever. Share it and you’ve handed one person total control. And there is no native, on-chain way to pass assets to an heir without doing one of those two things.

Tranfr adds a programmable recovery path: stay active and retain full control, but if you stop checking in for a defined period, a designated recovery recipient can claim the funds — enforced on-chain, without a custodian. A user creates a Tranfr, designates a recovery recipient, and sets an inactivity period (90, 180, or 365 days, or custom) fixed at creation. While the Tranfr is active, the owner can send a heartbeat to reset the timer, or update the recovery recipient — which also resets the timer. If no heartbeat or policy update occurs before the deadline, the owner path freezes and the recovery path becomes eligible: the recipient’s signature, combined with the expired timelock, becomes sufficient to claim the assets.

The mechanism is enforced entirely by CKB scripts and transaction rules — no custodian, oracle, or centralized service is involved.

This grant is scoped to validating and implementing a reusable CKB recovery primitive, then providing the minimum SDK and testnet reference implementation necessary to prove it is integrable/

2. The Problem

A private key can be lost, destroyed, forgotten, or held by someone who becomes incapacitated or unavailable. When that happens, the blockchain has no way of knowing the legitimate owner is gone — the Cell and its assets remain valid, but nobody may be able to satisfy the spending conditions.

Existing approaches each have tradeoffs: multisig requires multiple parties during normal operation; custodians introduce a trusted intermediary; manual inheritance arrangements rely on off-chain coordination; application-specific recovery systems aren’t reusable; and a conventional single-key lock has no fallback at all.

CKB provides programmable ownership conditions and transaction-level timelocks through since. Tranfr uses these existing primitives to build a recovery path without any protocol changes.

3. How It Works

Terminology, used consistently throughout:

  • Owner — the person currently controlling the Tranfr.

  • Recovery recipient — the person/address designated to receive the assets if the owner becomes inactive.

  • Heartbeat — an owner-signed transaction confirming continued control; resets the timer, leaves policy unchanged.

  • Policy update — an owner-signed transaction changing the recovery recipient, inactivity period, or other supported parameters; resets the timer and changes policy.

  • Recovery transaction — executed by the recipient after expiry.

Setup. The owner sets a recovery recipient and an inactivity period. The inactivity period is fixed at creation for this version — only the recovery recipient can be changed later via a policy update.

Active state. While active, the owner retains full control: they can heartbeat at any time to reset the timer without changing anything, or issue a policy update to change the recovery recipient, which also resets the timer. Ordinary CKB transactions (spends, Fiber activity, DeFi interactions) do not count as activity and do not reset the timer only an explicit heartbeat or policy update does. This avoids ambiguity about what constitutes “still active” and keeps the contract logic simple to audit.

Expiry. This is the critical state transition. Once the inactivity period lapses without a heartbeat or policy update, the recovery policy becomes eligible and can no longer be modified by the owner the owner can no longer heartbeat, change the recipient, or extend the timer. Eligibility isn’t automatic execution: the Cell isn’t transferred on its own. The recovery recipient must submit a recovery transaction, which the script validates against two conditions — the recipient’s signature, and the since-enforced expiry, both of which must hold. Before expiry, the recipient’s signature alone is insufficient regardless of validity; after expiry, it becomes sufficient. No centralized approval is needed either way.

4. Technical Architecture

Tranfr Lock Script — a lock script for CKB-VM with two logical operation groups:

  • Owner operations (while active): normal spend, heartbeat, and policy update (recovery recipient only, in this version). Heartbeat and policy update are kept as distinct state transitions — a heartbeat only resets the timer, a policy update changes the recipient and resets the timer. Keeping these separate.

  • Recovery operation (after expiry): a recovery transaction validated against the recipient’s signature and the elapsed since condition.

Key technical point: the owner-side freeze after expiry is enforced by an independent chain-time check in the lock script (via a header dependency), not by since alone — since only gates the recovery path’s minimum-time condition. Milestone 1 validates this mechanism against CKB’s actual transaction/header semantics before the lock script implementation is finalized, and functions as a hard technical gate: if the header-time mechanism cannot establish the required irreversible expiry invariant under CKB semantics, the implementation design is revised before substantial coding begins.

Policy state. Each Tranfr commits to an owner, a recovery recipient, an inactivity period (fixed at creation), and a recovery deadline. The inactivity timer represents the maximum permitted period since the owner’s last valid heartbeat or policy update — nothing else resets it.

TypeScript SDK — createTranfr(), getTranfrPolicy(), getTranfrStatus(), buildHeartbeat(), buildPolicyUpdate(), buildRecovery(), isRecoveryEligible(). Handles transaction construction while keeping the underlying policy transparent and independently verifiable.

State machine:

CREATE → ACTIVE ⇄ (heartbeat / policy update, timer resets)

ACTIVE → (no activity before deadline) → EXPIRED → RECOVERY ELIGIBLE → RECOVERY TX → CLAIMED

5. Security Model

The recovery recipient cannot claim before expiry — the since timelock is enforced at the script level regardless of signature validity. After expiry, the owner path is frozen by an independent chain-time check in the lock script rather than by since alone: the script rejects any owner heartbeat or policy update once current chain time passes the stored deadline, so the recovery state is immutable once reached.

While the Tranfr is active, the owner is intentionally authorized to modify the recovery, this means the owner retains full control over who can eventually recover the assets for as long as they’re demonstrably present and active. That authority ends the moment expiry is reached.

Securing the recovery recipient’s own key is the recipient’s responsibility, on the same terms as any other CKB key — Tranfr doesn’t introduce new custody requirements on that side, it uses the recipient’s existing signing capability.

This POC is scoped to a single designated recipient as the entire v1 model. Social or threshold multi-party recovery is real added complexity, better suited to a future development if the core primitive is validated.

The entire security boundary is the CKB lock script itself. Since this POC has no off-chain notification component, there’s no off-chain trust surface to reason about in this phase — every guarantee is enforced by the script and independently verifiable.

6. Deliverables

  • Tranfr Lock Script — Implement with owner operations (spend, heartbeat, policy update) and the recovery operation, plus a comprehensive test suite covering both state transitions, adversarial cases, expiry-boundary conditions, and the frozen-after-expiry invariant.

  • Tranfr SDK — open-source TypeScript library for policy creation, inspection, heartbeat and policy-update construction, recovery transaction construction, and status calculation.

  • Testnet Demo — an CLI/reference script demonstrating the full lifecycle on testnet: create Tranfr, heartbeat, policy update, expiry, recovery. Its purpose is to prove the SDK and script work end-to-end, not to be an application — no UI polish, no broader integration surface.

  • Documentation — technical specification, security model, threat model, transaction flow, integration guide, test vectors.

All code will be open-sourced.

POC Scope

one owner + one recovery recipient + one immutable inactivity period + heartbeat + recipient update + recovery.

Out of scope for this grant:

Multiple recovery recipients, social recovery, configurable activity sources, automatic notifications, production wallet integration, production security audit

7. Roadmap

Milestone Timeline Work
1. Protocol & feasibility Week 1 State machine, threat model, transaction model, and proof that expiry/freeze semantics work as intended. If the header-time expiry-enforcement mechanism cannot be validated against CKB semantics, the implementation design is revised before substantial coding begins.
2. Core CKB primitive Weeks 2–4 Lock script + owner ops (spend/heartbeat/policy update) + recovery op + owner-side expiry check + adversarial and expiry-boundary tests
3. SDK Weeks 5–6 Minimal TypeScript SDK for policy creation, inspection, and transaction construction
4. Testnet validation Week 7 Thin CLI/reference demo exercising the complete lifecycle
5. Release Week 8 Documentation, test vectors, integration example, open-source release

8. Budget — $1,600 USD

Area Amount
CKB scripts + policy/state enforcement + testing $1,050
TypeScript SDK $350
Testnet demo + documentation $200

9. Success Criteria

A developer should be able to: create a Tranfr, set a recovery recipient and inactivity period, change the recipient while active and verify the timer resets, send a heartbeat and verify it resets the timer without altering policy, allow the timer to expire, verify that both heartbeat and policy updates are rejected after expiry, execute recovery with the designated recipient, and confirm recovery is invalid before expiry and valid after. Most importantly, a third-party wallet or application should be able to integrate Tranfr via the SDK without depending on the reference application’s own architecture.

The critical security test: an owner must not be able to extend or modify an expired Tranfr by submitting a heartbeat or policy update after the recovery deadline has passed.

10. Ecosystem Value

Tranfr is built as a primitive that any CKB wallet or app can integrate, rather than a single-purpose inheritance app. The same mechanism can eventually support personal wallet recovery, inheritance, business continuity, DAO treasury recovery, lost-device protection, and long-term cold storage — but the first version implements only the simplest model: one owner, one recovery recipient, one inactivity period fixed at creation, with recipient-only policy control while active. That keeps the POC achievable while establishing a foundation other applications can build on rather than reimplementing their own version.

CKB’s Cell Model and since mechanism make this possible without any protocol changes: ownership conditions and temporal conditions are both expressible directly on-chain, so the recovery guarantee doesn’t require a third party to enforce it.

5 Likes

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

3 Likes

Wow, I really appreciate you looking into this. Thanks for the suggestion, it fixes the biggest open risk in the proposal with stale-header attacks on the freeze mechanism.

1 Like

I saw @Ajay worked on something similar, it’s worth checking out:

1 Like

Thanks for the pointer, took a look. Ajay is solving a different problem and approach.

It’s a fixed-date vault (lock funds, unlock at one target block/timestamp), no check-in or reset mechanism. Tranfr’s core piece is the inactivity-based check-in: owner can keep extending indefinitely via heartbeat, and the hard part is making that freeze irreversible once expiry hits without letting the owner backdate a late operation.

Different primitive, adjacent category. Good to have on the radar though, appreciate you flagging it.

Second boundary: ordinary owner spending must commit the exact effect before expiry

The remaining owner-side boundary is ordinary spending.

The proposal currently requires all three of the following:

  1. while active, the owner retains normal spending authority;
  2. an ordinary spend does not reset the inactivity timer;
  3. after expiry, the owner path is irreversibly frozen.

Those requirements are consistent only if an ordinary spend is no longer a single-step owner-signature path. A transaction signed before D but committed after D cannot be treated as timely, and since cannot provide an upper-validity bound.

The safe construction is to separate owner authorization from value release:

ACTIVE
  -> owner-signed BEGIN_SPEND
       -> PENDING_SPEND
            -> SETTLE_SPEND   if begin_epoch < old_deadline
            -> RECOVER        if begin_epoch >= old_deadline

BEGIN_SPEND does not transfer protected value to the intended recipient. It only places the exact intended spend effect on-chain.

Pending state

For a minimal v1 transfer profile, the pending state should contain the previous policy together with a complete, canonical spend plan:

PendingSpend {
    version

    previous_state_hash
    previous_deadline_epoch
    inactivity_period_epochs

    owner_lock_hash
    recovery_lock_hash
    state_nonce

    spend_plan_hash
    protected_value_commitment
}

SpendPlan {
    domain_separator          // TRANFR_SPEND_V1
    source_state_hash
    source_state_nonce

    ordered_outputs           // full CellOutput + outputs_data
    residual_output_index     // optional
    exact_fee_capacity
}

The full SpendPlan must remain available from the pending state itself, or from an immutable Cell whose outpoint and data hash are committed by PENDING_SPEND. Storing only a hash is not sufficient: if the owner disappears after BEGIN_SPEND, the recovery recipient or another executor must still be able to reconstruct and settle the authorized effect without obtaining an off-chain preimage from the owner.

The output commitment must cover the complete serialized output objects:

capacity
lock script
type script
output data
ordering

Committing only to a recipient and amount would leave room to substitute type state, change data, alter residual ownership, or move value through an uncommitted output.

BEGIN_SPEND rules

BEGIN_SPEND must enforce:

owner signature is valid

exactly one PENDING_SPEND successor exists

previous policy is copied exactly
previous deadline is copied exactly
state identity and nonce are bound

all protected capacity and typed value remain inside the pending state
no protected value is released in BEGIN_SPEND

the complete SpendPlan is canonical and available on-chain
spend_plan_hash matches the canonical plan bytes

the plan is satisfiable from the protected input
the fee is exact, not open-ended
any residual output preserves the Tranfr policy and old deadline

no heartbeat effect occurs
no recipient update occurs
no deadline extension occurs

For v1, the safest transfer profile is deliberately narrow:

one pending Tranfr input
no mutable external input dependency
no uncommitted output
no arbitrary callback
fee funded from the committed plan
exact output set

This gives the second phase a deterministic result that any party can submit.

Commitment-bound time decision

When PENDING_SPEND is consumed, the lock loads the header associated with that exact input Cell:

ckb_load_header(..., input_index, CKB_SOURCE_INPUT)

Then:

begin_epoch = epoch of the block that committed PENDING_SPEND
timely      = epoch_less(begin_epoch, previous_deadline_epoch)

As in the heartbeat/update path, the comparison must use CKB epoch-number-with-fraction semantics.

The signature time and broadcast time are irrelevant. The only deciding fact is when the pending state entered the canonical chain.

Timely spend

If:

begin_epoch < old_deadline

the owner committed the spend while authority still existed.

The second transaction may therefore execute only the exact committed plan:

SETTLE_SPEND:
    spend_plan_hash matches
    outputs match ordered_outputs exactly
    outputs_data match exactly
    protected value allocation matches exactly
    transaction fee equals exact_fee_capacity
    no additional protected route is introduced

The second phase should not require another owner signature. The owner already authorized the exact effect in BEGIN_SPEND. Making settlement permissionless prevents the protected value from becoming stuck if the owner disappears immediately after committing the pending state.

This does not preserve discretionary owner authority after expiry. It preserves only one already-fixed transition.

Any residual Tranfr output must carry:

same owner
same recovery recipient
same inactivity period
same old deadline
next state nonce

An ordinary spend therefore does not reset activity. If settlement occurs after the old deadline, the residual state is already recovery-eligible.

Late spend

If:

begin_epoch >= old_deadline

the spend plan has no effect.

The late pending state must not be able to:

release value
change a recipient
reduce the recoverable amount
change the recovery lock
extend the deadline
create a new owner-controlled residual

Only recovery under the previous policy is valid:

recovery signer = previous_recovery_lock
input since      = absolute epoch equal to old_deadline
recovered value  = the complete protected value

A transaction signed before D but kept offline and committed after D therefore cannot reserve a spend.

Recovery from a timely pending spend

A timely PENDING_SPEND must not expose a competing branch that allows the recovery recipient to ignore the committed outputs and redirect the full value.

The deterministic rule is:

timely pending spend
    -> exact committed spend must settle first
    -> only the residual remains under the old recovery policy

Because settlement is permissionless and the full plan is available on-chain, the recovery recipient can settle the plan themselves and then recover any residual whose old deadline has already passed.

This removes destination ambiguity:

before D:
    owner may commit one exact spend effect

after D:
    nobody may replace that effect with a different one

Scope boundary for Fiber and DeFi

A deterministic CKB transfer and an arbitrary Fiber or DeFi interaction are not the same transaction class.

A general application interaction may depend on:

other live Cells
changing liquidity
slippage bounds
counterparty inputs
callbacks
application-specific state transitions
mutable route composition

Those effects cannot safely be represented by an unconstrained generic SPEND flag.

For the POC, I would define ordinary spending as a canonical deterministic transfer profile. Broader Fiber/DeFi support should use a separate, integration-specific action commitment layer—potentially compatible with CoBuild-style action messages—where every integration defines exactly which fields may vary and which final invariants must remain fixed.

Until that layer exists, unsupported composable routes should fail closed rather than silently inherit ordinary owner authority.

Minimum blocking tests

TEST 01
BEGIN_SPEND committed before D
SETTLE_SPEND committed before D
-> exact outputs are accepted
-> residual keeps old deadline

TEST 02
BEGIN_SPEND committed before D
SETTLE_SPEND committed after D
-> exact committed outputs are still accepted
-> residual is immediately recovery-eligible
-> deadline is not reset

TEST 03
BEGIN_SPEND signed before D but committed after D
-> spend plan has no effect
-> full protected value remains recoverable

TEST 04
recipient, capacity, type script, lock script, output data, or output order is changed
-> settlement fails

TEST 05
fee differs from the committed fee
-> settlement fails

TEST 06
an extra protected output, input, callback, or uncommitted route is introduced
-> settlement fails

TEST 07
owner disappears after BEGIN_SPEND
-> a third party can settle the exact on-chain plan without an owner signature

TEST 08
recovery attempts to bypass a timely spend plan and take the full value
-> recovery fails
-> committed spend must settle first

TEST 09
residual state changes recovery policy or derives a new deadline
-> settlement fails

TEST 10
the same SpendPlan is replayed against a later Tranfr state
-> source_state_hash / state_nonce binding fails

The core invariant is:

Before expiry, the owner may commit an exact value transition. After expiry, the owner retains no open-ended authority to choose or modify a transaction.

And the recovery invariant is:

Recovery may claim the residual value, but it may not overwrite an exact spend effect that entered the canonical chain before expiry.

This should be fixed in the protocol model before ordinary spending is treated as a transparent owner operation in the lock script or SDK.

References

1 Like