Skip to content

fix(zeronym): the shim discarded the hub's queue-hit sentinel - #80

Merged
aphelionz merged 5 commits into
mainfrom
fix/zeronym-shim-queue-hit-sentinel
Sep 23, 2026
Merged

aphelionz merged 5 commits into
mainfrom
fix/zeronym-shim-queue-hit-sentinel

Conversation

@aphelionz

Copy link
Copy Markdown
Member

The bug

45e408f stopped the hub from serving a queued migration's bytes: a queue hit is answered found, height 0, with an empty body, so a not-yet-published transaction cannot be taken from an unauthenticated lookup and broadcast first. smoke.sh was updated to accept that reply. The shim was not.

get_transaction ran every Lookup::Found through the L4 guard, which deserializes the returned bytes and compares txids. An empty body does not deserialize, so the guard returned false and the shim answered NOT_FOUND with not_found_message, which synthesizes lightwalletd's getrawtransaction ... failed: -5 text locally. That is why the failure reads as though the operator's indexer answered; the operator was never dialled.

This is not a smoke-test problem. With this shim a wallet that diverts a migration can never see it as pending. The height-0 queue answer is the entire existence-and-status signal the stateless-shim design depends on, and the shim converted every one of them into NOT_FOUND.

The fix

The sentinel takes its own arm ahead of the guard. The guard's intent is right (a hub must not substitute a different transaction's bytes) but an empty body carries no bytes to substitute, so there is nothing to verify and nothing to protect. Height 0 only: a mined transaction always has bytes, so an empty body at a nonzero height is not a queue hit and still falls through to the guard, which refuses it.

One arm covers both transports. The clearnet path was checked and needs no change: the hub's found() sets content-type and x-tx-height unconditionally, HubClient::get_transaction keys on that header shape rather than on body length, and LookupReplyV1 carries tx_len = 0 cleanly in both directions. intercept.rs was the single point of failure.

Evidence

Confirmed against the real public Nym mixnet on 2026-09-15, with a 20s delay between the divert submit and the lookup so no race is possible:

hub   13:45:04.943 DEBUG migration admitted to the batch parseable=true
hub   13:45:24.851 DEBUG transaction lookup answered source="queue"
shim  13:45:26.901 WARN  the hub returned a transaction whose txid does not match the query; refusing it

The only flush in that run was the shutdown flush at 13:45:27, well after the lookup. Not a flush race, and not a failure to queue.

Tests

Every existing mock hub returned the full V6_MIGRATION bytes for a height-0 hit; none modelled the post-45e408f0ff reply. Five new tests across both transports:

  • the queue-hit sentinel is relayed as pending (status 0, empty data, height 0, operator never dialled)
  • an empty body at a mined height is still refused, pinning the height-0-only condition
  • divert.rs gains the mismatched-txid refusal it was missing, which divert_nym.rs already had, so widening the guard cannot pass unnoticed

The new pending test fails on the parent commit. The guard tests pass with and without the fix, so the fix is not what makes them green.

Second commit: smoke.sh arrival ordering

Separate and independent. Over the mixnet the submit is fire-and-forget (NymHandle::submit returns as soon as the frame is handed to the Nym client, with a locally-computed txid), so the submit and the lookup are two Sphinx packets with no ordering guarantee. In a run without a settle delay the hub logged the admit and the lookup miss 44 ms apart.

The divert lookup now retries a NOT_FOUND every 2s up to SMOKE_DIVERT_SETTLE_SECS (10) rather than sleeping before it: an in-order run pays nothing, a hub that never queued the transaction still fails with the same message, and SMOKE_LOOKUP_MAX_SECS keeps measuring one call rather than the sum.

Verification

  • full zero-indexer-shim test suite green
  • sh zeronym/smoke-local.sh against the public Nym mixnet: 9/9, shim:divert reporting height=0 from the hub's queue, body withheld

EXPECTED_SHA256 is stale as of the shim source change and will be re-baselined on this branch from this PR's zeronym-shim-reproduce cold build.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 16, 2026 01:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Address the digest reproducibility issue and both smoke-test retry issues.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes Zeronym queued-transaction handling, adds bounded smoke-test retries, and updates Zallet proposal funding error mapping.

Changes:

  • Relays height-0 empty-body queue sentinels.
  • Adds regression tests and arrival-order retry handling.
  • Maps insufficient-funds proposal errors to RPC code -6.
File summaries
File Summary
zeronym/smoke.sh Adds bounded lookup retries; restrict retries to NOT_FOUND and respect the remaining timeout budget.
zeronym/shim/tests/divert.rs Adds clearnet sentinel and transaction-validation tests.
zeronym/shim/tests/divert_nym.rs Adds mixnet sentinel coverage.
zeronym/shim/src/intercept.rs Relays queued sentinels before transaction validation; refresh the deployment digest.
zallet/zallet-core/src/components/json_rpc/payments.rs Maps proposal funding failures to legacy RPC errors.
zallet/CHANGELOG.md Documents the error-code change.
Review details

Suppressed comments (1)

zeronym/smoke.sh:657

  • The check compares the previous _waited value and then always sleeps a full two seconds, so a non-multiple budget is exceeded; for example, SMOKE_DIVERT_SETTLE_SECS=1 still sends another lookup at about 2 seconds. Test the next retry interval against the remaining budget (or clamp the sleep) before sleeping.
    [ "$_waited" -lt "$SMOKE_DIVERT_SETTLE_SECS" ] || break
    sleep "$DIVERT_RETRY_STEP_SECS"
    _waited=$((_waited + DIVERT_RETRY_STEP_SECS))
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread zeronym/shim/src/intercept.rs
Comment thread zeronym/smoke.sh Outdated
45e408f stopped the hub from serving a queued migration's bytes: a queue
hit is now answered found, height 0, with an EMPTY body, so a
not-yet-published transaction cannot be taken from an unauthenticated lookup
and broadcast first. smoke.sh was updated to accept that reply. The shim was
not, and it rejects it.

`get_transaction` ran every `Lookup::Found` through the L4 guard, which
deserializes the returned bytes and compares txids. An empty body does not
deserialize, so the guard returned false and the shim answered NOT_FOUND with
`not_found_message`, which synthesizes lightwalletd's "getrawtransaction
... failed: -5" text LOCALLY. That is why the failure reads as though the
operator's indexer answered; the operator was never dialled.

This is not a smoke-test problem. With this shim a wallet that diverts a
migration can never see it as pending: the height-0 queue answer is the entire
existence-and-status signal the stateless-shim design depends on, and the shim
converted every one of them into NOT_FOUND.

The sentinel now takes its own arm ahead of the guard. The guard's intent is
right, a hub must not substitute a different transaction's bytes, but an empty
body carries no bytes to substitute, so there is nothing for it to verify and
nothing for it to protect. Height 0 only: a mined transaction always has
bytes, so an empty body at a nonzero height is not a queue hit and still falls
through to the guard, which refuses it.

One arm covers both transports. The clearnet path was checked and needs no
change: the hub's `found()` sets content-type and x-tx-height unconditionally,
`HubClient::get_transaction` keys on that header shape rather than on body
length, and `LookupReplyV1` carries tx_len = 0 cleanly in both directions.
`intercept.rs` was the single point of failure.

Confirmed against the real public Nym mixnet on 2026-09-15, with a 20s delay
between the divert submit and the lookup so no race is possible: the hub
logged `migration admitted to the batch` and, twenty seconds later,
`transaction lookup answered source="queue"`, while the shim logged the L4
refusal. Not a flush race, and not a failure to queue.

Tests, on both transports, because every existing mock hub returned the full
V6_MIGRATION bytes for a height-0 hit and none modelled the post-45e408f0ff
reply. The new pending test fails on the parent commit; the guard tests pass
with and without the fix, so the fix is not what makes them green. divert.rs
also gains the mismatched-txid refusal it was missing, which divert_nym.rs
already had, so widening the guard cannot pass unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 16, 2026 01:40
@aphelionz
aphelionz force-pushed the fix/zeronym-shim-queue-hit-sentinel branch from 0dcfbfb to e93da3f Compare September 16, 2026 01:40
`check_shim_divert` submits a migration and then looks it back up once. Over
the mixnet those are two independent Sphinx packets with no ordering
guarantee between them: `NymHandle::submit` is fire-and-forget, returning as
soon as the frame is handed to the Nym client and answering the wallet with a
locally-computed txid, so the lookup can reach the hub first. On 2026-09-15
the hub logged the admit and the lookup miss 44 ms apart, and the check failed
for a migration that was queued correctly.

Retry a NOT_FOUND every 2s up to SMOKE_DIVERT_SETTLE_SECS (10), rather than
sleep before the lookup. A run whose packets arrive in order pays nothing; a
hub that never queued the transaction still fails with the same message, now
saying how long it waited; and SMOKE_LOOKUP_MAX_SECS keeps measuring one call,
not the sum, so the latency assertion is unchanged.

NOT_FOUND alone is retried. Arrival order is the only thing a retry can fix,
and it is the only thing a 5 is ambiguous about; UNAVAILABLE means the hub is
unreachable, which a deployer needs told at once, and INVALID_ARGUMENT can
never become true by waiting. The queue explanation is printed under a 5 for
the same reason: under a 14 it would send someone looking for a flush that
never happened.

Separate from the shim fix in the parent commit on purpose. That was the real
bug and this would have hidden part of it; the retry is worth having on its
own merits, against a transport that genuinely does not order its packets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aphelionz
aphelionz force-pushed the fix/zeronym-shim-queue-hit-sentinel branch from e93da3f to 9a424f7 Compare September 16, 2026 01:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved moderate findings remain in the smoke retry handling and deployment hash baseline.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

zeronym/shim/src/intercept.rs:391

  • This source change changes the shim binary, but zeronym/shim/deploy/EXPECTED_SHA256 remains at the previous digest (the PR description explicitly notes it is stale). deploy/reproduce.sh will therefore fail its published-hash check and the attestation/provenance baseline does not describe this commit's binary; include the cold-build re-baseline, along with the corresponding deploy README record, before merging.
        Ok(Lookup::Found { data, height }) if data.is_empty() && height == 0 => {

zeronym/smoke.sh:654

  • This condition retries every nonzero gRPC status, not just NOT_FOUND. For example, a fast hub-unreachable response is grpc-status: 14, so the smoke check sends repeated mixnet lookups and waits through the full settle budget before reporting the same outage. Gate the retry on status 5 so other failures remain immediate.
  # wallet needs told immediately, and INVALID_ARGUMENT (3) can never become

zeronym/smoke.sh:657

  • The retry step can exceed the configured settle budget whenever SMOKE_DIVERT_SETTLE_SECS is not a multiple of 2. For example, setting the documented override to 1 still sleeps for 2 seconds and performs a retry after the budget has expired. Sleep for the smaller of the fixed step and the remaining budget.
  # YET", and only a 5 is retried.
  _waited=0
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread zeronym/smoke.sh Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 01:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Rebaseline the deployed hash and provenance, and ensure retries remain within the configured settle budget.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

zeronym/shim/src/intercept.rs:393

  • This source change alters the deployed shim binary, but zeronym/shim/deploy/EXPECTED_SHA256 still contains the pre-change hash (da0b12a6…). deploy/reproduce.sh therefore fails its published-hash check and the attestation/reproducibility documentation continues to identify the old binary; please include the cold-build rebaseline (and its accompanying provenance update) in this PR.
        Ok(Lookup::Found { data, height }) if data.is_empty() && height == 0 => {
            Ok(get_transaction_response(&data, height))
        }

zeronym/smoke.sh:664

  • This does not actually bound retries to SMOKE_DIVERT_SETTLE_SECS: _waited counts only the sleeps, so each NOT_FOUND call may first consume the full SMOKE_LOOKUP_HARD_SECS (90s by default). A slow NOT_FOUND can therefore trigger all retries and make the advertised 10s settle take several minutes; track a wall-clock deadline or cap each call to the remaining budget.
    [ "$_waited" -lt "$SMOKE_DIVERT_SETTLE_SECS" ] || break
    sleep "$DIVERT_RETRY_STEP_SECS"
    _waited=$((_waited + DIVERT_RETRY_STEP_SECS))
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread zeronym/smoke.sh Outdated
The parent commits change `intercept.rs`, so the published binary hash moves
with them. Measured the way deploy/README.md's procedure asks: the source
landed first, then `EXPECTED= sh zeronym/shim/deploy/reproduce.sh` ran from
that commit, so the value describes a tree that exists rather than a working
tree that may never be committed.

dae86efed5dbbce185539afa0bdb033ff8a61aec306e83d44c4c28ba00f132db, agreed by
four cold builds across two machines and two architectures: two on a native
x86_64 CI runner and two locally on an arm64 Mac under Rosetta, with `zebra/`
and `zaino/` clean each time. The recipe did not move, and neither did
`Cargo.toml` or `Cargo.lock`; the tests and `smoke.sh` in the parent commits
are not compiled inputs.

The string check has nothing to report this time, which is itself worth
recording. The fix is control flow and introduces no string, so `strings`
cannot distinguish this binary from da0b12a6...; the L4 warning is still
present exactly once, as it must be, since a genuine txid mismatch is still
refused. The behavioural distinguisher is the test suite and smoke-local.sh.

EXPECTED_SHA256 and the Recorded hashes table move together, as the file's own
rule requires, with da0b12a6... going down to the superseded row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 16, 2026 03:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

zeronym/smoke.sh can sleep beyond SMOKE_DIVERT_SETTLE_SECS for non-multiple-of-two budgets.

Review details

Suppressed comments (1)

zeronym/smoke.sh:664

  • This fixed two-second sleep can exceed the configured settle budget whenever SMOKE_DIVERT_SETTLE_SECS is not a multiple of two (for example, a value of 1 sleeps for 2 seconds and performs another lookup). Since this variable is documented as how long retries may run, cap the final sleep to the remaining budget.
    [ "$_waited" -lt "$SMOKE_DIVERT_SETTLE_SECS" ] || break
    sleep "$DIVERT_RETRY_STEP_SECS"
    _waited=$((_waited + DIVERT_RETRY_STEP_SECS))
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 23, 2026 14:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Update probe handling for the sentinel and cap smoke-test waits to the configured budget.

Review effort: Lite
Findings: 2 Medium severity

Open (2)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

The smoke retry loop can exceed its configured settle window and lacks a bounded end-to-end lookup budget.

Review effort: Lite
Findings: 2 Medium severity

Open (2)

The retry loop slept a fixed 2s step, so a budget below or off a multiple of
the step overran it (1 slept 2). The last step now sleeps only what is left,
and the NOT_FOUND note reports the seconds actually waited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 23, 2026 16:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

smoke.sh retries all status-5 responses, including permanent guard refusals, which can be misreported as queue misses.

Review effort: Lite
Findings: None

Resolved since last review (2)

The shim answers a txid-guard refusal with NOT_FOUND, identical on the wire
to a queue miss by design, so the smoke note blamed the queue for both. The
note now names the guard case and the log line that identifies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 23, 2026 16:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

No unresolved review comments remain, and the changes include regression coverage.

Review effort: Lite
Findings: None

@aphelionz
aphelionz merged commit ee910d6 into main Sep 23, 2026
27 checks passed
@aphelionz
aphelionz deleted the fix/zeronym-shim-queue-hit-sentinel branch September 23, 2026 17:00
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.

2 participants