Skip to content

refactor(e2e): observe on-chain outcomes past the RPC wait window - #4108

Open
kdeforth-bot wants to merge 3 commits into
near:3164-assert-onchain-timeout-in-recon-testfrom
kdeforth-bot:kd/e2e-call-timeout
Open

refactor(e2e): observe on-chain outcomes past the RPC wait window#4108
kdeforth-bot wants to merge 3 commits into
near:3164-assert-onchain-timeout-in-recon-testfrom
kdeforth-bot:kd/e2e-call-timeout

Conversation

@kdeforth-bot

Copy link
Copy Markdown

Stacked on #4051 — the base branch is 3164-assert-onchain-timeout-in-recon-test, so this
diff is just the one commit on top of Simon's work. Opened from a fork for access reasons;
the commit is authored by @kevindeforth.

Problem

A sign is a yield-resume promise that stays unresolved until the nodes answer or the
protocol times the yield out ~200 blocks later. That is far longer than nearcore's RPC
polling window (10s, chain/jsonrpc/src/lib.rs:134, across near-kit's 3 retries — roughly
45s in total).

So a slow or unanswerable request fails at the transport layer and its on-chain outcome is
never observed. The author sees contract call failed: <rpc timeout>, indistinguishable
from a broken test, and the real result (RequestError::Timeout) cannot be asserted. Four
separate workarounds for this had accumulated: the select! metric races in
timeout_metric and distinct_reconstruction_thresholds, the submit-and-poll ladder added
by #4051, and the devnet loadtest's own poll loop.

Change

NearKitCaller takes an optional timeout.

  • None — today's .send() path, untouched. near-kit's timeout is mapped to
    CallError::Deadline so the diagnostic points at the pending request instead of claiming
    the call failed.
  • Some(d) — submit, wait for Included so the transaction is known to the RPC, then
    poll tx_status to ExecutedOptimistic until the outcome exists or the deadline passes,
    retrying only while near-kit reports a retryable error.

The transaction is never re-broadcast: retrying send_tx would re-submit it, so inclusion
is confirmed once and only observation is repeated.

Output stays FinalExecutionOutcome, so no call site signature moves. The knob reaches
the typed handle through one new generic method on the shared crate:

cluster.contract_handle(&user)                                    // unchanged behavior
cluster.contract_handle(&user).with_timeout(CLUSTER_WAIT_TIMEOUT) // observe the yield

Effect on #4051

This removes wait_tx_final, call_from_with_deposit_included and
send_sign_request_included. The assertion added by #4051 survives unchanged — only the
mechanism beneath it does, and it now goes through the typed handle rather than around it.
timeout_metric loses its select! race too, and keeps its metric assertion, now sequenced
after the outcome rather than racing it.

Verification

PASS [48.626s] timeout_metric::timeout_metric__should_increment_when_signature_times_out
PASS [58.270s] distinct_reconstruction_thresholds::..._should_use_per_domain_threshold_when_nodes_are_down
Summary [58.270s] 2 tests run: 2 passed, 23 skipped

cargo clippy -p e2e-tests -p near-mpc-contract-interface --all-targets --locked -- -D warnings
and cargo fmt --check are clean. The rest of the e2e suite has not been run on this branch.

🤖 Generated with Claude Code

kevindeforth and others added 3 commits August 10, 2026 13:03
A `sign` is a yield-resume promise that stays unresolved until the nodes
answer or the protocol times the yield out ~200 blocks later. That is far
longer than nearcore's RPC polling window (10s, across near-kit's retries),
so a slow or unanswerable request failed at the transport layer and its
on-chain outcome was never observed — reported as `contract call failed`,
indistinguishable from a broken test.

`NearKitCaller` now takes an optional timeout. Without one it behaves exactly
as before. With one it submits, waits for inclusion so the transaction is
known to the RPC, then polls `tx_status` to `ExecutedOptimistic` until the
outcome exists or the deadline passes, retrying only while near-kit reports a
retryable error. The transaction is never re-broadcast.

`CallError::Deadline` names the pending transaction instead of claiming the
call failed, so the next author is told to widen the window rather than left
guessing at a flake.

Both tests that worked around this now assert the on-chain timeout directly:
the metric race in `timeout_metric` becomes an outcome assertion followed by
the metric check, and `distinct_reconstruction_thresholds` drops its parallel
submit-and-poll ladder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CallError::Deadline` carried `tx: Option<CryptoHash>` while its doc claimed the
transaction was on chain. Only one of the two paths could promise that: the
timed path waits for inclusion before polling, so it holds a real hash, while
the untimed path never confirms inclusion and never learns one. The `Option`
was carrying that distinction silently, and the two cases want different
remedies — widen an existing deadline, or set one at all.

They are now separate variants, so `Deadline` is unconditionally true to its
name and `RpcGaveUp` keeps the underlying error as a `#[source]` instead of
discarding it.

`call_contract` becomes a dispatcher over `send` and `send_and_observe`, which
puts each waiting strategy in one place rather than interleaving them behind a
`let ... else`.

Comments audited against docs/engineering-standards.md: dropped the ones
restating a signature or naming a caller, and corrected the `NearKitCaller`
doc, which still pointed at `Deadline` for a path that now yields `RpcGaveUp`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`NearKitCaller` kept its own copy of the account id purely to pass a sender to
`tx_status`. `near_kit::Near` already exposes the signer's account, so the copy
was duplicated state that nothing kept in sync — a caller built with a
different signer would have polled under the wrong account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.await
.expect("sign request did not reach an on-chain outcome");
assert!(
outcome.is_failure(),

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.

So, this, IMO, is the biggest improvement of the approach in this and PR #4051 - we can now actually wait for the sign request to fail.

@kevindeforth

kevindeforth commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

@SimonRastikian The main difference to #4051 is that here, we allow any contract method to be called with a custom timeout and still rely on the contract interface trait.

.await
{
Ok(outcome) => return Ok(outcome),
Err(e) if !is_retryable(&e) => return Err(CallError::Rpc(e)),

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.

I took me a couple of minutes to understand this line having the definition of is_retriable. I was wondering mosly whether it's not possible to simplify the logic.

}

/// Submitted once: retrying `send_tx` would re-broadcast, so only the polling repeats.
async fn send_and_observe(

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.

I expect from the function to send something just like the previous one calling internally call.send().
I did not see where it sends. Could you guide me through what you are doing here please?

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.

This function submits the signed transaction to the chain and then waits for the transaction outcome.

@SimonRastikian

Copy link
Copy Markdown
Contributor

@claude review this PR

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Pull request overview

This replaces four ad-hoc workarounds for nearcore's RPC polling window with one knob on the e2e transport. NearKitCaller gains an optional timeout: without it the existing .send() path is untouched (only the error mapping improves), with it the call submits, confirms Included, then polls tx_status to ExecutedOptimistic until the outcome exists or the deadline elapses. Because Output stays FinalExecutionOutcome, the knob reaches the typed handle through one new generic combinator (MpcContractHandle::map_caller) and no call-site signature moves. Both tests that previously raced a doomed sign future against a metric now assert the on-chain outcome directly.

Changes:

  • NearKitCaller carries timeout: Option<Duration>; call_contract dispatches to send (unchanged behavior) or send_and_observe (submit -> confirm inclusion -> poll).
  • New CallError replaces near_kit::Error as the transport error type, splitting "on chain but unresolved" (Deadline) from "the RPC stopped waiting, inclusion unconfirmed" (RpcGaveUp).
  • New WithTimeout trait on MpcContractHandle<NearKitCaller>, backed by MpcContractHandle::map_caller in the shared interface crate.
  • Removes wait_tx_final, call_from_with_deposit_included and send_sign_request_included (all added on the base branch), plus the select! metric race in timeout_metric.
  • distinct_reconstruction_thresholds tightens its assertion from "timed out" to "Request has timed out." - which correctly pins RequestError::Timeout (crates/contract/src/errors.rs:44, raised by fail_on_timeout at crates/contract/src/lib.rs:2656) and no longer also matches RequestNotFound (errors.rs:144).

Reviewed changes

Per-file summary
File Description
crates/e2e-tests/src/blockchain.rs Adds the optional timeout, send/send_and_observe split, CallError, WithTimeout, and the is_retryable/timed_out_waiting predicates; drops the two base-branch helpers
crates/e2e-tests/src/cluster.rs Removes send_sign_request_included and its now-unused SignArgs import
crates/e2e-tests/src/lib.rs Re-exports CallError and WithTimeout
crates/e2e-tests/tests/timeout_metric.rs Replaces the select! race with an outcome assertion followed by the metric check
crates/e2e-tests/tests/distinct_reconstruction_thresholds.rs Replaces the submit-and-poll ladder with contract_handle(..).with_timeout(..).sign(..); tightens the failure-message assertion
crates/near-mpc-contract-interface/src/client.rs Adds MpcContractHandle::map_caller
crates/e2e-tests/Cargo.toml, Cargo.lock Adds thiserror to e2e-tests

Findings

Blocking (must fix before merge):

  • crates/e2e-tests/src/blockchain.rs:97 - Deadline discards every error the poll loop swallowed, and asserts a fact the code never established. The Err(_) => sleep(..) arm drops e, so after 240 s of 500 ms polls the only diagnostic is tx {tx} still unresolved on chain after 240s. That sentence is only true when the RPC was answering; if the sandbox RPC was refusing connections or 503-ing the whole time, the reader is told the yield is open when in fact nothing was ever observed. The code being removed in this same diff did carry it - wait_tx_final reported "tx {tx_hash} did not reach Final within {timeout:?}: {e}". Losing it is a regression against this PR's own stated goal. Thread the last error out of the loop and attach it as a #[source] field on Deadline (e.g. capture into an Option<near_kit::Error> that the async block writes on each retry, and fold it in when tokio::time::timeout elapses).

  • crates/e2e-tests/README.md:132 - doc drift. The README documents this exact layer (NearKitCaller "binds a signer to a non-contract account ... and implements the CallContract transport trait") and now describes a type that has been reshaped: new timeout field, with_timeout, and a transport error type that is no longer near_kit::Error. The trap this PR exists to remove - a sign whose yield outlives the RPC wait window, and the fact that with_timeout is how you observe it - is documented only in a rustdoc comment in blockchain.rs. Per CLAUDE.md's Documentation alignment section ("Doc drift is a review-blocking issue, not a follow-up"), add a sentence here and/or under "Writing a test", since the next author who hits this will read the README, not blockchain.rs.

Non-blocking (nits, follow-ups, suggestions):

  • crates/e2e-tests/src/blockchain.rs:71 - the caller's timeout bounds only poll, not the wait_until::<Included>() above it, and an inclusion failure is mapped to CallError::Rpc. So on the timed path - where the author explicitly asked to wait for the outcome - a slow inclusion still surfaces as contract call failed: <rpc timeout>, the message this PR set out to eliminate. Cheap fix: route that error through timed_out_waiting as well, so it lands in RpcGaveUp.
  • crates/e2e-tests/tests/timeout_metric.rs:56 vs crates/e2e-tests/src/blockchain.rs:63 - the two comments contradict each other. send_and_observe says "Submitted once: retrying send_tx would re-broadcast, so only the polling repeats", while the test says "Inclusion may be re-sent by near-kit's transport retries, so a duplicate request can bump this twice". The test's mechanism also looks wrong: a near-kit retry re-broadcasts the same signed transaction, which has the same hash and is applied once, so it cannot produce a second sign request. Either verify the claim or restore the previous hedged wording ("in case the send below has retry mechanism") - an incorrect rationale is worse than none, and it is the justification for using >= instead of ==.
  • crates/e2e-tests/tests/timeout_metric.rs:47 - now that the outcome is in hand, assert why it failed, as the sibling test does at distinct_reconstruction_thresholds.rs:118. is_failure() alone also passes for an insufficient deposit or an unknown domain, and the metric wait would then fail 240 s later with a message pointing at the indexer.
  • crates/e2e-tests/src/blockchain.rs:162 - timed_out_waiting and is_retryable are pure predicates over near_kit::Error and carry the whole mechanism: misclassify one and the poll loop either bails on the first tx_status timeout or spins to the deadline. Neither is covered - the RpcGaveUp path in particular is exercised by no test. If near_kit::RpcError is constructible outside the crate, a couple of unit tests here would pin the classification per engineering-standards.md "Add tests".
  • crates/e2e-tests/src/blockchain.rs:77 - the // May not panic note (correct idiom per "Don't panic") anchors its argument on the preceding statement rather than on the invariant that actually holds: NearKitCaller is only ever constructed from make_client, which always installs a signer. Anchoring on "the call above required a signer" is the sequential dependency "Maintain Local Reasonability" warns about - reorder the two statements and the comment silently becomes wrong.
  • Scope note: the PR body counts the devnet loadtest's poll loop among the four workarounds this change subsumes, but crates/devnet/src/loadtest.rs:395 still has it. It talks to near-jsonrpc-client directly rather than through CallContract, so leaving it is reasonable - worth saying so in the description, or filing a follow-up, so the count is not misleading.

⚠️ Issues found

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.

3 participants