Skip to content

fix(wallet): discipline reservation liveness against a monotonic clock - #539

Merged
MichaelTaylor3d merged 11 commits into
mainfrom
loop/532-clock-jump-monotonic
Sep 3, 2026
Merged

fix(wallet): discipline reservation liveness against a monotonic clock#539
MichaelTaylor3d merged 11 commits into
mainfrom
loop/532-clock-jump-monotonic

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

What changed

Reservation liveness (RESERVATION_TTL_MS / MAX_RESERVATION_HOLD_MS, #502/#525/#528) is judged
entirely on raw wall-clock readings. #528 (open, not yet merged) closes the case where the clock is
already wrong at the moment a reservation is FIRST written -- a self-contradiction between a row's
own two columns.

This closes the general form (#532): a wall clock that steps FORWARD while a reservation is
already live, mid-hold
-- an NTP step correction, a VM pause/resume, an operator setting the
clock -- produces no such contradiction (the anchor is unchanged and the jumped reading looks like
an ordinary "now"). The very next liveness check reads the jump as elapsed time and can retire a
bundle's hold while it is still genuinely in flight, with no bound at all on how far forward
the step goes -- the #348/#497 double-spend direction, and strictly worse than #528's bounded
110-minute residual for a bad reading at write time.

ClockGovernor (new, crates/dig-wallet/src/sage/custody.rs) disciplines every
reservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a
monotonic clock says real time has actually elapsed. WalletBackend now reads reservation
"now" through self.reservation_now_ms() (a thin wrapper sharing one Arc<Mutex<ClockGovernor>>
across every Clone, same pattern as custodied_public_keys) instead of calling
super::custody::now_ms() directly, at all 6 production call sites in the reservation lifecycle
(reservations_held, reserve_coins, reserve_pushed_bundle, get_pending_transactions,
spendable_coins, the CAT-send path).

The shape, and why

Tests

crates/dig-wallet/src/sage/custody.rs, mod tests (new), 5 tests, all passing:

  • a_wall_clock_matching_real_time_passes_through_unclamped -- no clamp in the ordinary case.
  • a_forward_jump_mid_hold_is_clamped_to_real_elapsed_time -- a 1-hour wall-clock jump with only
    100ms real elapsed is clamped to 100ms.
  • a_backward_step_passes_through_unclamped -- confirms the deliberate asymmetry.
  • the_clamp_releases_itself_once_real_time_catches_up -- proves this is not a freeze: tracking
    resumes automatically once real elapsed time reaches the jumped value.
  • a_forward_clock_jump_mid_hold_no_longer_releases_a_live_reservation_early -- the money
    property, built from the REAL WalletDb::reserve_spend/prune_reservations API (no hand-placed
    row, same shape fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528's own regression tests use): a bundle pushed at t=0, then a raw wall-clock
    jump that would read as past the reservation's deadline, does NOT release the coin when pruned
    through the governed clock; it releases only once real elapsed time actually reaches the TTL.

Every pure-decision test uses Instant::now() + Duration to construct two readings a known
distance apart -- exact and deterministic, no sleeping, no fake-clock trait needed.

Regression check -- every existing reservation-lifecycle test still passes after routing through
the governor (cargo test -p dig-wallet --lib reserv, run against the built WalletBackend
production paths, not just the DB layer):

running 19 tests
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 788 filtered out

cargo test -p dig-wallet --lib sage::custody::tests:

running 5 tests
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 802 filtered out

cargo clippy -p dig-wallet --lib -- -D warnings: clean, exit 0.
cargo check -p dig-wallet --lib: clean, exit 0.
rustfmt --check --edition 2021 on both touched files: clean, exit 0 (touched files only, not
cargo fmt --all).

Versioning -- NEEDS THE MAIN LANE

crates/dig-wallet/Cargo.toml is bumped 0.48.0 -> 0.48.1 (patch: internal bug fix, no API
change) and Cargo.lock is refreshed to match.

The root [workspace.package].version (the released dig-node binary version, currently
0.254.0) is intentionally left UNCHANGED.
Per the dispatching brief: the minor field is at its
ceiling (package-version.sh caps minor <= 255) and another lane is fixing the version scheme
under #521/#522 right now. Requesting the correct next root version rather than inventing one --
the Check version increment CI job will fail on this PR until that number is supplied and
applied.

Status

DRAFT. Do not merge, do not undraft -- handing back to the main lane per the dispatch brief.

Closes #532

Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock
readings. #528 closes the case where the clock is already wrong at the
moment a reservation is FIRST written. It does not close the general form
(#532): a wall clock stepped FORWARD while a reservation is already live,
mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock --
produces no self-contradiction for #528's check to catch, yet the very next
prune reads the jump as elapsed time and can retire a bundle's hold while it
is still genuinely in flight, with no bound on how far forward the step
goes (the #348/#497 double-spend direction).

Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading
so it cannot advance, between two observations, faster than a monotonic
clock says real time has actually elapsed. A forward wall-clock jump is
absorbed rather than trusted and the disciplined clock simply runs behind
until real time catches up, at which point it resumes tracking the wall
clock with no special unfreeze step. A backward step is passed straight
through unclamped, since it can only lengthen a hold, never shorten one --
the safe direction #502/#528 already accept elsewhere.

The governor lives for the process's lifetime and is not persisted: a
restart re-seeds it from the wall clock at that moment, so a clock already
wrong at boot remains #528's write-time contradiction check's problem, not
this one's.

Closes #532

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 4 commits September 3, 2026 10:51
Root workspace version, per the main lane -- the minor field's version scheme
is being fixed separately under #521/#522; this is the interim number to
carry PR #539 (dig-node#532) through the version-increment gate.

Cargo.lock refreshed in the same commit (cargo update -w --offline) so
dig-node-service's locked entry matches -- every CI job runs --locked, and a
manifest-only bump here fails Clippy/Test+coverage/all three package builds
together on a change that cannot otherwise break a build.

Co-Authored-By: Claude <noreply@anthropic.com>
…onotonic

# Conflicts:
#	Cargo.lock
#	Cargo.toml
…onotonic

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	SPEC.md
…onotonic

# Conflicts:
#	Cargo.lock
#	Cargo.toml
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
Merged origin/main forward (7042f89, #535) rather than rebasing -- the queue
across the five open PRs sequences this one last (#542 0.254.42, #539 0.254.43,
#543 0.254.44, #536 0.254.50, this PR 0.254.51), deliberately: #542 touches
ensure-version-increment.yml, which this PR's follow-up note also targets, so
landing last means the final shape is on main rather than guessed at.

Re-read the version from Cargo.toml on disk (not the commit log) after the
merge, per the standing caution that a rebase can silently drop a bump commit
as "already upstream" -- this was a merge, and the version file itself
confirms 0.254.51 post-bump.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…collision with #542

#542 keeps 0.254.42 (urgent required-CI-gate PR, merges first); 0.254.43 is
reserved for #539, which this branch sits on top of. Version assignment
across concurrent PRs is the coordinator's per CLAUDE.md section 1.4.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 3 commits September 3, 2026 12:54
Per the main lane: main advanced to exactly 0.254.41 after #535's rebase,
tying this branch's version. 0.254.43 clears main and every sibling PR in
the version-bump queue (#542=0.254.42, #543=0.254.44, #536=0.254.50,
#544=0.254.51).

Cargo.lock re-synced with `git checkout origin/main -- Cargo.lock` followed
by `cargo update -w --offline` (never hand-editing lock conflict markers),
confirmed clean with `--dry-run` -> `Locking 0 packages`.

Co-Authored-By: Claude <noreply@anthropic.com>
…onotonic

# Conflicts:
#	Cargo.lock
#	Cargo.toml
….254.61)

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 3, 2026 20:29
@MichaelTaylor3d
MichaelTaylor3d merged commit ea6ff37 into main Sep 3, 2026
9 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/532-clock-jump-monotonic branch September 3, 2026 20:30
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…543)

* fix(wallet): discipline reservation liveness against a monotonic clock

Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock
readings. #528 closes the case where the clock is already wrong at the
moment a reservation is FIRST written. It does not close the general form
(#532): a wall clock stepped FORWARD while a reservation is already live,
mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock --
produces no self-contradiction for #528's check to catch, yet the very next
prune reads the jump as elapsed time and can retire a bundle's hold while it
is still genuinely in flight, with no bound on how far forward the step
goes (the #348/#497 double-spend direction).

Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading
so it cannot advance, between two observations, faster than a monotonic
clock says real time has actually elapsed. A forward wall-clock jump is
absorbed rather than trusted and the disciplined clock simply runs behind
until real time catches up, at which point it resumes tracking the wall
clock with no special unfreeze step. A backward step is passed straight
through unclamped, since it can only lengthen a hold, never shorten one --
the safe direction #502/#528 already accept elsewhere.

The governor lives for the process's lifetime and is not persisted: a
restart re-seeds it from the wall clock at that moment, so a clock already
wrong at boot remains #528's write-time contradiction check's problem, not
this one's.

Closes #532

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

* chore(release): bump workspace version to 0.254.20

Root workspace version, per the main lane -- the minor field's version scheme
is being fixed separately under #521/#522; this is the interim number to
carry PR #539 (dig-node#532) through the version-increment gate.

Cargo.lock refreshed in the same commit (cargo update -w --offline) so
dig-node-service's locked entry matches -- every CI job runs --locked, and a
manifest-only bump here fails Clippy/Test+coverage/all three package builds
together on a change that cannot otherwise break a build.

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

* fix(wallet): route wallet_reset_coin_db's now through ClockGovernor

wallet_reset_coin_db read its now_ms from a fresh, undisciplined
SystemTime::now() rather than WalletBackend::reservation_now_ms(), so a
wall-clock jump mid-hold (an NTP correction, a VM pause/resume) could make
its in-flight-spend check see a still-live reservation as already expired
and let the reset proceed -- the #348/#497 double-spend direction, no
attacker required.

reservation_now_ms() is now pub so the control plane (a different crate)
can route through it, sharing the same ClockGovernor clamp state every
other reservation call site (reserve_coins, prune_reservations) already
uses.

Swept every reservation-touching path in dig-wallet and dig-node-service
for a direct SystemTime::now() read; this was the only production one.

Closes #541

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

* chore(release): bump dig-node 0.254.42 / dig-wallet 0.49.0

dig-wallet: minor -- reservation_now_ms is now a public API surface
(dig-node-service routes through it, dig-node#541).
dig-node: patch -- behaviour fix, no breaking change.

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

* chore(release): re-bump to 0.254.44 -- coordinator-assigned to avoid collision with #542

#542 keeps 0.254.42 (urgent required-CI-gate PR, merges first); 0.254.43 is
reserved for #539, which this branch sits on top of. Version assignment
across concurrent PRs is the coordinator's per CLAUDE.md section 1.4.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…bump (#544)

* fix(release): guard the MSI cross-release ordering hazard at a MAJOR bump

scripts/package-version.sh's MINOR-overflow carry (#537, closing #521/#522) folds an
overflowing MINOR into the idle MSI major field and is monotonic for the whole
MAJOR==0 lifetime, but it cannot see a cross-release hazard: a carried release like
0.511.0 (MSI 1.255.0) can compare HIGHER under msiexec's numeric ordering than a
later, perfectly legal 1.0.0 (MSI 1.0.0). An in-version guard can't catch this --
1.0.0 alone is not illegal, only a specific predecessor makes it a downgrade.

Add an optional second argument, PREV_VERSION (the previous stable release's bare
X.Y.Z). When supplied, the script folds it with the same rule and refuses to emit
a version whose MSI tuple compares LOWER than PREV_VERSION's -- turning the "pick a
high-enough MAJOR-bump number" decision into a machine-checked one instead of an
unchecked human call. Omitting it (every existing package.yml callsite) is
byte-for-byte unchanged; ensure-version-increment.yml already checks out both the
PR head and main, so a follow-up can wire main's version in at zero extra cost
(sequenced separately against #542, which is adding a step to that same job).

Measured (dig-node#540): MINOR has never exceeded 255 in any released dig-node
version, so nothing shipped is affected today -- this closes the hazard before it
can ever be reached rather than reacting once it is.

Also corrects "dig_ecosystem#521/#522" references in this script/test/SPEC to
"#521/#522" -- those are dig-node's own issues, not the unrelated dig_ecosystem
tickets of the same numbers.

Revert-proof: stripping the guard reproduces the exact hazard as an accepted exit 0
(0.511.0 -> 1.0.0), and reinstating it passes the full suite (58 ok / 0 fail).

Closes #540

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

* chore(release): re-bump to 0.254.51 -- main took 0.254.41 (#535)

Merged origin/main forward (7042f89, #535) rather than rebasing -- the queue
across the five open PRs sequences this one last (#542 0.254.42, #539 0.254.43,
#543 0.254.44, #536 0.254.50, this PR 0.254.51), deliberately: #542 touches
ensure-version-increment.yml, which this PR's follow-up note also targets, so
landing last means the final shape is on main rather than guessed at.

Re-read the version from Cargo.toml on disk (not the commit log) after the
merge, per the standing caution that a rebase can silently drop a bump commit
as "already upstream" -- this was a merge, and the version file itself
confirms 0.254.51 post-bump.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

Reservation lifetimes are anchored on wall-clock time, so any clock jump misjudges a live hold

1 participant