fix: clamp DevMiner timestamp above tx parents - #173
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthrough
ChangesParent timestamp clamping
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
tests/test_dev_miner.pytxstratum/dev/manager.py
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>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/test_dev_miner.py (1)
117-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReduce duplication between the two backend factories.
make_backendandmake_backend_with_sessiondiffer only in how the session is built. Letmake_backenddelegate tomake_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_sessionabovemake_backendso 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
📒 Files selected for processing (2)
tests/test_dev_miner.pytxstratum/dev/manager.py
jansegre
left a comment
There was a problem hiding this comment.
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.
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>
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.timestampat 1-second granularity, but the dev-miner assigned the two independently: parents arrived as bare hashes and the timestamp was alwaysint(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_verifiercompares against the DAG parents,transaction_verifiercompares 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 blindnow + 1does 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
hathorlibexposes 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 futurehathorlibaccessor (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
HathorClienton a live fullnode, since every unit test mocks the backend and the fallback path is by design indistinguishable from success.Acceptance criteria
Summary by CodeRabbit