Skip to content

[Bug] Reservation extensions can finalize after the source tx disappears because finalize does not re-check source-tx evidence #405

@JSONbored

Description

@JSONbored

Summary

The reservation-extension flow can propose an extension when a source tx is visible but unconfirmed. If that tx later disappears, is RBF-replaced away from the miner payment, or otherwise becomes unverifiable before the challenge window closes, validators currently do not challenge the pending extension. They return early when tx_info is None.

At the next eligible block, the same path finalizes the pending extension before it checks tx_info. The reservation deadline is extended even though the source-tx evidence that justified the extension is gone.

Result: a user/operator can keep a miner reservation alive with stale extension evidence, pinning the slot longer than the normal reservation TTL without a still-verifiable source transaction.

Local proof

Mocked forward-loop proof:

proposal_count_after_visible_unconfirmed_tx= 1
proposal_target_block= 340
pending_after_propose= PendingExtension(submitter='other-validator', target_block=340, proposed_at=100)
challenges_while_tx_missing_before_window_close= 0
finalizes_before_window_close= 0
finalizes_after_tx_missing= 1
challenges_after_tx_missing= 0
local_reserved_until_updates= [('5aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 340)]

The proof sequence:

  1. At block 100, try_extend_reservation(..., tx_info=visible_unconfirmed) proposes an extension.
  2. At block 104, the same reservation has tx_info=None; no challenge is issued.
  3. At block 108, the challenge window is eligible; try_extend_reservation(..., tx_info=None) finalizes the extension and updates local reserved_until to the target.

No live funds are touched.

Code path

try_extend_reservation() fetches pending extension state and finalizes it before checking whether the source tx is still visible:

allways/validator/forward.py:275-294
pending = self.optimistic_extensions.fetch_pending_reservation(item.miner_hotkey)
finalized_target = self.optimistic_extensions.maybe_finalize_reservation(...)
if finalized_target is not None:
    self.state_store.update_reserved_until(item.miner_hotkey, finalized_target)
    reserved_until = finalized_target
    pending = None

allways/validator/forward.py:296-297
if tx_info is None:
    return

The challenge path is below that tx_info is None return, so a missing/disappeared tx prevents challenge:

allways/validator/forward.py:307-313
self.optimistic_extensions.maybe_challenge_reservation(
    miner_hotkey=item.miner_hotkey,
    from_chain_id=item.from_chain,
    current_block=current_block,
    reserved_until=reserved_until,
    pending=pending,
)

The local proposal path explicitly allows both tiers to fire on tx visibility alone:

allways/validator/optimistic_extensions.py:78-81
Both tiers fire on tx visibility alone with identical runway.

The contract stores the pending extension with only target/proposed-at metadata. It emits from_tx_hash, but it does not store or enforce any source-tx evidence at finalize time:

smart-contracts/ink/lib.rs:651-697
pub fn propose_extend_reservation(
    &mut self,
    miner: AccountId,
    from_tx_hash: Hash,
    target_block: u32,
) -> Result<(), Error> {
    ...
    self.pending_reservation_extensions.insert(
        miner,
        &PendingExtension { submitter: caller, target_block, proposed_at: current },
    );
    self.env().emit_event(ReservationExtensionProposed {
        miner,
        from_tx_hash,
        target_block,
        by: caller,
    });
}

Finalize only checks that the challenge window has elapsed and that the reservation has not already expired:

smart-contracts/ink/lib.rs:721-759
pub fn finalize_extend_reservation(&mut self, miner: AccountId) -> Result<(), Error> {
    let Some(pending) = self.pending_reservation_extensions.get(miner) else { ... };
    let current = self.env().block_number();
    if current < pending.proposed_at.saturating_add(CHALLENGE_WINDOW_BLOCKS) {
        return Err(Error::ChallengeWindowOpen);
    }
    let Some(mut reservation) = self.reservations.get(miner) else { ... };
    if reservation.reserved_until < current { ... }
    reservation.reserved_until = pending.target_block;
    self.reservations.insert(miner, &reservation);
    ...
}

Why this matters

Reservation extensions are supposed to rescue honest source transactions that are visible but not confirmed quickly enough. The current state machine keeps the positive evidence from the proposal block, but it does not require that evidence to remain true through the challenge/finalize window.

For BTC, that matters because unconfirmed transactions can disappear from an endpoint, be evicted, or be RBF-replaced. If the original tx no longer pays the miner, validators should not finalize an extension based on the stale earlier observation.

In the active exploit context, this is another way to pin or rotate miner reservations without the normal slot pressure applying. It is separate from completed-volume scoring: this issue lives in the reservation extension evidence path.

Duplicate check

Raw authenticated GraphQL duplicate sweep checked:

"finalize_extend_reservation" "tx_info"
"finalize" "reverify"
"source tx" "disappear"
"RBF" "reservation"
"dropped" "source tx" "extension"
"maybe_challenge_reservation" "tx_info"
"tx_info is None" "finalize"
"ReservationExtensionProposed" "challenge"
"reservation extension" "tx"
"extend reservation" "tx"
"pending confirm" "extension"
"mempool" "reservation"

Closest related items:

No existing open/closed issue or PR was found for re-checking source-tx evidence before reservation-extension finalization or challenging when tx_info becomes None.

Expected direction

Possible fixes:

  • Move challenge evaluation before the tx_info is None return, and challenge pending extensions when the source tx that justified the extension is no longer visible or no longer matches sender/recipient/amount.
  • Re-verify source tx evidence before finalizing a reservation extension; skip finalization when the evidence is gone.
  • Store enough proposal evidence locally to compare the pending extension's from_tx_hash to the queued confirm and challenge mismatches.
  • For BTC, treat RBF replacement away from the miner payment as negative evidence, not as a benign propagation miss.

The key invariant: a reservation extension should only finalize while the source tx evidence that justified it is still valid.

Validation

Command run from the repo root:

uv run python - <<'PY'
from allways.validator.forward import try_extend_reservation
from allways.validator.optimistic_extensions import OptimisticExtensionWatcher
...
PY

Result:

challenges_while_tx_missing_before_window_close= 0
finalizes_after_tx_missing= 1
local_reserved_until_updates= [('5aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 340)]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions