Skip to content

fix: clamp DevMiner timestamp above tx parents - #173

Open
tuliomir wants to merge 4 commits into
HathorNetwork:masterfrom
tuliomir:fix/devminer-parent-timestamp
Open

fix: clamp DevMiner timestamp above tx parents#173
tuliomir wants to merge 4 commits into
HathorNetwork:masterfrom
tuliomir:fix/devminer-parent-timestamp

Conversation

@tuliomir

@tuliomir tuliomir commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Part of #172 — this is the first of the two stages that issue proposes, so it does not close it.

The fullnode requires tx.timestamp > parent.timestamp at 1-second granularity, but the dev-miner assigned the two independently: parents arrived as bare hashes and the timestamp was always int(time()). Two transactions broadcast in the same second, where one is chosen as the other's parent, produced a deterministic rejection no client could prevent or repair — both fields are assigned inside the service, under the nonce.

The fullnode applies this rule in two separate verifiers and both must hold: vertex_verifier compares against the DAG parents, transaction_verifier compares against every tx spent by an input. Neither set contains the other — parents come from the current tips, while a spent tx may have been confirmed long ago and so can never be selected as a parent.

The mining path now reads the timestamps of both sets from the fullnode, deduped, and stamps max(now, max_predecessor_timestamp + 1). Reading the real timestamps is what makes a same-second chain resolve; a blind now + 1 does not, because a predecessor may itself have been clamped into that same second.

This stays inside consensus rules: hathor-core tolerates timestamps up to MAX_FUTURE_TIMESTAMP_ALLOWED (300s) ahead, and this exceeds "now" by at most one second per same-second parent chain.

Implementation notes

  • Parent timestamps are cached per hash, bounded at 1024. A confirmed transaction's timestamp is immutable so an entry cannot go stale, and shared DAG tips make the hit rate high under parallel load.
  • A parent whose timestamp cannot be read falls back to plain wall-clock time — today's exact behavior — rather than failing the job.
  • hathorlib exposes no "get transaction by hash", so the client's session is used directly inside a single helper. Scoped that way deliberately: the fix stays in this repo, and a future hathorlib accessor (or a parents endpoint that returns timestamps) changes only that one method body.

Scope

Dev-miner only, per the staged rollout in the issue: it is the variant integration suites run against, so clients can validate it under real parallel load at zero risk to production. The production path (MinerTxJob.update_timestamp) is deliberately untouched and remains exposed — that is the follow-up.

Validation

Measured against wallet-lib's integration suite on a privnet with 4 parallel jest workers and the client-side retry workaround reverted, so the service had to stand on its own.

Across four full runs — the last one on the current base, with Python 3.11, hathorlib 0.14.1 and the non-root container — 2932 transactions mined, 0 parent-timestamp rejections, 0 full-validation failures, 0 consensus violations in the resulting DAG.

Scope of that evidence: those runs exercised the parents-only clamp, which was all the code did at the time. The extension to spent txs landed afterwards and is covered by unit tests only — mutation-verified (reverting to parents-only fails test_clamps_past_a_spent_tx_that_is_not_a_parent), but not yet re-validated under parallel integration load. The input rule was never observed firing in those 2932 transactions, so the gap it closes is latent rather than demonstrated.

Zero rejections alone would be ambiguous — it is equally consistent with "no collision occurred". Reconstructing the DAG from the service logs shows 59 transactions whose newest parent was stamped at or after their own solve second: precisely the condition that produced 73 rejections per run in the issue's baseline. Every one of them was stamped past its parent instead.

The clamp was also exercised against a real HathorClient on a live fullnode, since every unit test mocks the backend and the fallback path is by design indistinguishable from success.

Acceptance criteria

  • Transactions mined by the dev-miner always carry a timestamp strictly greater than every parent's, including when a parent was stamped in the same second or was itself clamped forward.
  • A parent timestamp that cannot be read degrades to the previous wall-clock behavior instead of failing the job.

Summary by CodeRabbit

  • Bug Fixes
    • Improved transaction timestamp handling during development mining.
    • Ensured mined transactions receive timestamps later than known parent and input transactions.
    • Added reliable fallback behavior for unavailable, invalid, or malformed timestamp data.
    • Preserved client-provided timestamps when valid.
    • Reduced duplicate and unnecessary timestamp lookups.
    • Added bounded caching to improve performance and reliability.
    • Preserved mining functionality when timestamp data is unavailable.

The fullnode requires tx.timestamp > parent.timestamp at 1-second
granularity, but the dev-miner assigned the two independently: parents
came back from the backend as bare hashes, and the timestamp was always
int(time()). Two txs broadcast in the same second, where one is picked
as the other's parent, produced a deterministic rejection that no
client could prevent or repair — both fields are assigned here, under
the nonce.

The mining path now reads the parents' timestamps from the fullnode and
stamps max(now, max_parent_timestamp + 1). Reading the real parent
timestamps is what makes a same-second chain resolve; a blind now + 1
does not, since the parent may itself have been clamped into the same
second.

Notes on the implementation:

- Timestamps are cached per parent hash, bounded. A confirmed tx's
  timestamp is immutable, so a cached entry cannot go stale, and the
  shared DAG tips make the hit rate high under parallel load.
- A parent whose timestamp cannot be read falls back to plain
  wall-clock time, exactly today's behavior, rather than failing the
  job.
- hathorlib exposes no "get transaction by hash", so the client's
  session is used directly in a single helper. Scoped that way on
  purpose: the fix stays inside this repo, and a future hathorlib
  accessor changes only that one body.

Clamping stays inside consensus rules — hathor-core tolerates
timestamps up to MAX_FUTURE_TIMESTAMP_ALLOWED (300s) ahead, and this
exceeds "now" by at most one second per same-second parent chain.

Applies to the dev-miner only, as the staged rollout in
HathorNetwork#172 proposes: it is the variant
integration suites run against, so clients can validate it under real
parallel load at zero risk to production. The production path
(MinerTxJob.update_timestamp) is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tuliomir
tuliomir requested a review from msbrogli as a code owner August 6, 2026 13:58
@tuliomir tuliomir added bug Something isn't working tests labels Aug 6, 2026
@tuliomir tuliomir self-assigned this Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 435e0db6-037e-4ff3-9da3-89121e339346

📥 Commits

Reviewing files that changed from the base of the PR and between 50dc812 and 34519a4.

📒 Files selected for processing (2)
  • tests/test_dev_miner.py
  • txstratum/dev/manager.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_dev_miner.py

📝 Walkthrough

Walkthrough

DevMiningManager retrieves parent timestamps through the fullnode, caches them in a bounded LRU-style cache, and clamps mined transaction timestamps above known parents and spent inputs. Lookup failures fall back to current time. Tests cover retrieval, caching, fallback, timestamp precedence, and completed mining.

Changes

Parent timestamp clamping

Layer / File(s) Summary
Timestamp retrieval and selection
txstratum/dev/manager.py
DevMiningManager validates fullnode responses, tolerates lookup failures, maintains a bounded cache, deduplicates predecessor lookups, and selects a timestamp above known predecessor timestamps.
Mining timestamp integration
txstratum/dev/manager.py
Mining combines the client timestamp with the calculated timestamp and caches the final timestamp of successfully mined transactions.
Timestamp clamp validation
tests/test_dev_miner.py
Tests cover fake sessions, backend setup, timestamp clamping, fallback behavior, duplicate suppression, LRU eviction, client timestamp precedence, cache reuse, and completed mined transactions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • HathorNetwork/tx-mining-service#172 — Covers the parent timestamp lookup, caching, validation, clamping, and test behavior implemented here.
  • HathorNetwork/hathor-wallet-lib#1169 — Defines the related parent and spent-input timestamp clamping behavior.

Possibly related PRs

Suggested reviewers: msbrogli

Sequence Diagram(s)

sequenceDiagram
  participant TxJob
  participant DevMiningManager
  participant Fullnode
  participant MinedTransaction
  TxJob->>DevMiningManager: Provide client timestamp and transaction inputs
  DevMiningManager->>Fullnode: Request missing predecessor timestamps
  Fullnode-->>DevMiningManager: Return validated timestamps or lookup failure
  DevMiningManager->>MinedTransaction: Assign timestamp above predecessors and inputs
  DevMiningManager->>DevMiningManager: Cache final mined timestamp
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: clamping DevMiner timestamps above transaction parents.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@txstratum/dev/manager.py`:
- Around line 250-252: The timestamp selection in _mine_job() can exceed
MAX_FUTURE_TIMESTAMP_ALLOWED when extending a parent already at the future-time
boundary. Defer or retry mining until wall-clock time advances, then recompute a
valid timestamp while preserving the strict parent-timestamp constraint; do not
clamp it. Add a boundary test covering a parent at the maximum allowed future
timestamp.
- Around line 230-234: Update _get_timestamp_for to validate that data is a
dictionary before calling data.get, and validate that the tx value is a
dictionary before accessing its timestamp. Return None for either malformed
structure while preserving the existing success and integer-timestamp checks,
and add coverage for non-object payloads and tx values such as arrays or null.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 37aac43e-c51c-44ea-a7a5-2abce49d5bfa

📥 Commits

Reviewing files that changed from the base of the PR and between 23250a8 and e455bdb.

📒 Files selected for processing (2)
  • tests/test_dev_miner.py
  • txstratum/dev/manager.py

Comment thread txstratum/dev/manager.py
Comment thread txstratum/dev/manager.py Outdated
tuliomir and others added 2 commits August 6, 2026 12:37
A malformed fullnode payload raised AttributeError past the exception
handler: `data.get(...)` and `data["tx"].get(...)` both assumed a dict,
so a JSON array, a null, or a non-object "tx" escaped through
asyncio.gather into _mine_job. The job then sat in MINING forever with
no cleanup scheduled and its entry never left tx_jobs. Reproduced for
all four shapes before fixing.

Every access is now type-checked, and the private-attribute access
moved inside the try as well. That is the same bug one level up: the
helper reaches into hathorlib's _session and _get_url, so a rename
there would have stranded every job needing a parent. Both now degrade
to plain wall-clock time, which is what the docstring always promised.

Also documents the one case the clamp does not defend against. The
lead over wall-clock follows max(0, parent_lead + 1 - d) for a child
stamped d seconds after its parent, so chaining faster than one
transaction per second grows it, one per second holds it steady, and
anything slower decays it — an elapsed second does not by itself
return the lead to zero. Sustained chaining on the order of 300
serialized links would breach the fullnode's future-timestamp limit.
No observed workload approaches that: the clamp fired 59 times in 2932
transactions across four integration runs, with the lead never
exceeding one second. Deferring and re-mining to cover it would add a
blocking loop that can livelock while chaining continues, so the bound
is recorded rather than enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fullnode applies the timestamp rule in two separate verifiers and
both must hold: vertex_verifier rejects tx.timestamp <= parent.timestamp
over the parents, and transaction_verifier rejects
tx.timestamp <= spent_tx.timestamp over every input. Only the first was
covered, so a tx spending a same-second output was still rejected
whenever that output's tx was not among the two chosen parents — and
since get_tx_parents returns 2 of N tips, a spent tx that something else
already confirmed can never be selected. Clamping a parent forward also
widened the window for its spender rather than closing it.

The mining path now passes parents and input tx_ids together, deduped,
through the same fetch and cache. Extending the set removed the empty
guard and the intermediate list, so the function is shorter than before.

Two robustness fixes in the same path, both cheap:

- The lookup gets an explicit 2s timeout. The session carries aiohttp's
  5-minute default and DevMiningManager never arms a job timeout, so a
  stalled fullnode would have held the job in MINING for that long. The
  clock is now read after the lookups rather than before.
- A non-200 response and a malformed payload log a warning. Previously
  only the exception path did, so an endpoint that stopped resolving
  would have disabled the clamp permanently with no signal at all. A
  success:false stays silent: it means the fullnode has not indexed the
  tx yet, which is the ordinary case for a tx broadcast moments ago.

Tests pin the new behavior by mutation: reverting to parents-only,
evicting most-recently-used instead of least, and dropping the cache's
recency update each fail exactly one test. The eviction order and
move_to_end were previously asserted by nothing. The session fakes now
accept **kwargs, matching aiohttp — without it the added timeout kwarg
raised a TypeError that the helper's own degradation path swallowed.

Deliberately not addressed, to keep the change proportionate: failing
the job from the mining task's done_callback (pre-existing, and a new
method for a path nothing can currently reach), and rejecting bool or
negative timestamps in the payload guard (both already degrade to
wall-clock time by arithmetic).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_dev_miner.py (1)

117-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reduce duplication between the two backend factories.

make_backend and make_backend_with_session differ only in how the session is built. Let make_backend delegate to make_backend_with_session.

♻️ Proposed refactor
 def make_backend(parents=None, *, parent_timestamps=None, session_status=200):
     """Mock HathorClient with the session and URL builder the lookup needs.
 
     Predecessors are unknown unless `parent_timestamps` names them.
     """
-    parents = parents if parents is not None else [b"\x00" * 32, b"\x01" * 32]
-    backend = MagicMock()
-    backend.get_tx_parents = AsyncMock(return_value=parents)
-    backend.push_tx_or_block = AsyncMock(return_value=True)
-    backend._session = FakeSession(parent_timestamps, status=session_status)
-    backend._get_url = lambda path: f"http://fullnode/v1a/{path}"
-    return backend
+    return make_backend_with_session(
+        FakeSession(parent_timestamps, status=session_status), parents
+    )

Move make_backend_with_session above make_backend so the name resolves at call time in either order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dev_miner.py` around lines 117 - 139, Refactor the test backend
factories so make_backend delegates to make_backend_with_session, passing a
FakeSession configured with parent_timestamps and session_status while
preserving the existing default parents and mock methods. Move
make_backend_with_session above make_backend so the delegated function is
defined before use, and remove the duplicated backend construction from
make_backend.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_dev_miner.py`:
- Around line 117-139: Refactor the test backend factories so make_backend
delegates to make_backend_with_session, passing a FakeSession configured with
parent_timestamps and session_status while preserving the existing default
parents and mock methods. Move make_backend_with_session above make_backend so
the delegated function is defined before use, and remove the duplicated backend
construction from make_backend.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5cfcf931-739e-47e0-842c-f830a7f92576

📥 Commits

Reviewing files that changed from the base of the PR and between e455bdb and 50dc812.

📒 Files selected for processing (2)
  • tests/test_dev_miner.py
  • txstratum/dev/manager.py

@jansegre jansegre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the changes and why they grew a bit more complex than what it initially appeared to be. One thing that bothers me a little is that updating a transaction's timestamp now requires several queries to the fullnode (naturally, to get the timestamp of every dependency, input/parent). Which seems wasteful. The impact might be small but it grows a lot with an influx of transactions.

Which makes me think that this might not actually be this service's responsibility.

Maybe instead of having tx-mining-service update the timestamp to the calculated precise best fit, it should take into account the original timestamp that the transaction arrived with as a floor, and the client that's using the service (usually a wallet) should already have calculated a "floor timestamp" that respects the node's rules, which makes more sense and the tx-mining-service won't have to do all those fetches.

@tuliomir tuliomir moved this from In Progress (Done) to In Progress (WIP) in Hathor Network Aug 6, 2026
Two sources of predecessor timestamps that cost nothing, so that a tx
with 200 inputs stops meaning 200 fullnode reads against an endpoint
rate-limited to 50 r/s.

The client's own timestamp is now a floor rather than something to
discard. It chose the inputs, so it is the one party that already knows
what they spend, and api.py has bounded the value to within
MAX_TIMESTAMP_DELTA of now before we ever see it. A client that clamps
above its own inputs therefore needs no per-input lookup here at all,
which is the only version of this that survives a 200-input tx.

Transactions mined here are recorded too, since a chained flock arriving
at one service is, by construction, mostly this service's own recent
output. The hash covers the timestamp, so such an entry stays valid
whatever happens to the tx afterwards.

Neither replaces the per-predecessor clamp; both remove reads from it.

Refs HathorNetwork#172

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working tests

Projects

Status: In Progress (WIP)

Development

Successfully merging this pull request may close these issues.

2 participants