feat(node): gate verify-foreign-tx on available chains via the watch channel - #3912
feat(node): gate verify-foreign-tx on available chains via the watch channel#3912anodar wants to merge 12 commits into
Conversation
…channel The per-request view-client call (chain_is_supported) is gone: request execution now checks a channel-backed supporters map on both the leader and follower paths. foreign_chain_policy::spawn_supporters_by_foreign_chain resolves the indexer's TLS-key channel against the running job's participant set and threshold-filters it (TODO(#3640): switch to the domain threshold); the coordinator spawns it per running job since it needs the current participants. The ReadSupportedForeignChain trait, its real/fake readers, the legacy view-client read, and the reader generic on IndexerAPI/Coordinator/MpcClient/VerifyForeignTxProvider are all removed.
|
@claude review |
Pull request overviewThis PR replaces the on-demand Changes:
Reviewed changesPer-file summary
FindingsNo blocking issues found. A few non-blocking observations: Non-blocking (nits, follow-ups, suggestions):
✅ Approved |
gilcu3
left a comment
There was a problem hiding this comment.
Left some comments. Not requesting changes because we probably need some discussion around this, and anyway we are not merging this soon.
My main concern is the use of Option in two places.
The first one if for the supported chains. As far I can see, this is a case that cannot happen in practice, once the node is parked on startup until it gets the first set, which it should anyway have as it already needs the contract state to do anything, and these two things are tied together (both as view methods in the contract). I drafted this alternative in #3966 and it seems to work. Happy to hear if there is a downside.
The other place where I think Option might be avoided is in the thresholds in crates/node/src/foreign_chain_policy.rs. The None branch seems to refer to the case where there are no foreign chain domains, but I think we should handle that explicitly, for example by not running the tasks in that case (or some more explicit method).
| // Also checked in `execute_foreign_chain_request`; checked early here | ||
| // because `take_owned` below irreversibly consumes a presignature. An | ||
| // availability flip between the two checks still costs one presignature. | ||
| ensure_chain_is_available( | ||
| &self.supporters_by_foreign_chain.borrow(), | ||
| &foreign_tx_request.request, | ||
| ) | ||
| .inspect_err(|_| metrics::MPC_NUM_VERIFY_FOREIGN_TX_UNAVAILABLE_CHAIN_REJECTIONS.inc())?; |
There was a problem hiding this comment.
I think I never considered this case before, in which something is supported and the not supported. It opens the possibility for race conditions. We probably need to have some robust way of handling this.
There was a problem hiding this comment.
We had similar discussion in #3853 (comment).
Worst case it either times out (if node dropped support for chain that we thought it supported) or we think it's not supported (if node enables support at the "right" time) and fail early, in which case retry will succeed.
We just also check availability (ensure_chain_is_available) here as a way to reduce that race condition window (although not by much), but not trying to eliminate it. Since all nodes are expected to support all the chains, this is similar to node going down/up race condition.
There was a problem hiding this comment.
Speaking of this, it's unfortunate that there's no way to batch multiple view calls into a single atomic request. Feels like it would be a useful feature. I wonder how complex this would be in nearcore to support.
Pull request overviewFollow-up review of the changes since gilcu3's round on 2026-07-24. I verified the four commits that landed after the last review ( gilcu3's feedback → resolution
FindingsNo blocking issues. Non-blocking (nits / follow-ups):
✅ Approved |
a80d3e8 to
93c9ab5
Compare
93c9ab5 to
a80d3e8
Compare
netrome
left a comment
There was a problem hiding this comment.
Somewhat shallow review, but overall this looks good to me. Thanks!
| tracing::error!(target: "mpc", "error reading foreign-chain supporters from chain: {:?}", e); | ||
| tokio::time::sleep(FOREIGN_CHAIN_SUPPORTERS_REFRESH_INTERVAL).await; |
There was a problem hiding this comment.
I know this predates this PR, but shouldn't we do exponential backoff here? It makes logs cleaner if the node gets stuck on this, and also avoids spamming if we'd get rate limited anywhere.
There was a problem hiding this comment.
I did have exponential backoff initially on that, but changed based on reviewers feedback.
There was a problem hiding this comment.
Hmm I'm not following the reasoning in that thread.
Here, the contract already has all the required API, so emitting an error seems correct - we don't expect this call to fail.
This reasoning seems to assume the contract lacking the API being the only reason for the call to fail. I see several levels of type-erased errors in the view client, so I wouldn't directly trust this to always hold. Are we sure about this? What about network errors? Or other issues in nearcore? How can we be so confident this won't fail? (I know Kevin raised the point, but I assume you had some understanding of this argument when you changed the code)
There was a problem hiding this comment.
How can we be so confident this won't fail?
We can't, and all of the cases you listed will start spamming with error logs.
but I assume you had some understanding of this argument when you changed the code
Only argument against exponential backoff was to save us code complexity. While I don't necessarily agree on it, worst case this will spam 1 log per second so it's not the end of the world. If it keeps spamming long enough (can't read contract state for some reason) we likely have bigger issue (but again not a good reason to avoid backoff).
There was a problem hiding this comment.
worst case this will spam 1 log per second so it's not the end of the world.
I was not aware of this. As an operator, 1 log per second is not the end of the world, but not too far either as it basically makes the log unreadable after a while
There was a problem hiding this comment.
Yeah these kinds of spammy error logs are very annoying, especially considering how clunky the log UX is through dstack, so imo the extra code complexity is worth it. But I wouldn't block the PR on it.
Also, exponential backoff is a well-established pattern in the code base, so I wouldn't consider reusing it in more places adding much complexity. Especially since we still have a loop here. But anyway, do as you please for now. If this happens in production and annoys us we'll revisit and fix it later.
| let (sender, receiver) = watch::channel(initial); | ||
| tokio::spawn(async move { | ||
| loop { | ||
| tokio::time::sleep(FOREIGN_CHAIN_SUPPORTERS_REFRESH_INTERVAL).await; |
There was a problem hiding this comment.
Every time we add one of these direct sleeps without properly injecting time, a kitten dies.
Here it would over-complicate the code to try to shoehorn in a proper time abstraction so that's a separate effort. But anyway.
There was a problem hiding this comment.
I had this as config parameter (arguably could have been placed to better place) so that I could set different values in production vs testing code, but changed to hardcoded value as per reviewers suggestion.
Some context in: #3641 (comment), but more was discussed offline on this.
There was a problem hiding this comment.
I don't mind the config parameter, I'm just ranting (off topic) about the lack of dependency injection. Time is I/O and I/O should be separated from business logic. When you don't, any code that touches time interactions becomes incredibly hard to test.
For example, if I have:
fn delayed_add(delay: Duration, a: u64, b: u64) -> u64 {
std::time::sleep(delay);
a.checked_add(b).expect("overflows aren't the point of this example")
}This is a very simple function, but how do we write unit tests for this? Yes, we can ignore the delay, but there's no way to control the delay or test it. In more complex systems this becomes a much more real problem since you can't control the timing between components which leads to huge gaps in what you can and cannot test, and buries interesting code behind race conditions and whatnot.
If I instead simply did:
fn delayed_add(sleep: impl Sleep,delay: Duration, a: u64, b: u64) -> u64 {
sleep.sleep(delay);
a.checked_add(b).expect("overflows aren't the point of this example")
}
trait Sleep {
fn sleep(duration: Duration);
}
struct RealTime {}
impl Sleep for RealTime {
fn sleep(duration: Duration){ std::time::sleep(duration) }
}I could easily inject a fake sleep in tests that records how long the function actually intended to sleep. Additionally I can introduce wait point to fine grained control over which sleep points are passed and when. This makes time testable, and also make unit tests that don't depend on time instant. You should never have to wait for a timeout in tests.
There was a problem hiding this comment.
Fully agree.
In other languages (e.g. Go), I'd stub out foreign_chain_supporters_refresh_interval as a function type variable (maybe even at the package level) and set to different functions depending on production vs testing (pretty close to what you're suggesting). Having time configurable was my way to achieve similar thing in Rust without changing function signature.
I'd rather not refactor this now though as this is existing pattern in the codebase and probably worth something agreeing with others.
There was a problem hiding this comment.
Yeah I'm a big fan of functional patterns, although it can sometimes be tricky in Rust if you want to take it too far due to lack of higher order data types etc.
I'd rather not refactor this now though as this is existing pattern in the codebase and probably worth something agreeing with others.
100% agree. My main purpose of the rant is just to create alignment of desired code patterns so we don't leak sub optimal patterns into new modules.
| // Also checked in `execute_foreign_chain_request`; checked early here | ||
| // because `take_owned` below irreversibly consumes a presignature. An | ||
| // availability flip between the two checks still costs one presignature. | ||
| ensure_chain_is_available( | ||
| &self.supporters_by_foreign_chain.borrow(), | ||
| &foreign_tx_request.request, | ||
| ) | ||
| .inspect_err(|_| metrics::MPC_NUM_VERIFY_FOREIGN_TX_UNAVAILABLE_CHAIN_REJECTIONS.inc())?; |
There was a problem hiding this comment.
Speaking of this, it's unfortunate that there's no way to batch multiple view calls into a single atomic request. Feels like it would be a useful feature. I wonder how complex this would be in nearcore to support.
gilcu3
left a comment
There was a problem hiding this comment.
Thank you for all the fixes!
Closes #3910
Followup on #3852
NOTE: this should not be merged until predecessor is not released and deployed (one that registers config to the new endpoint), currently available chains is an empty list.