sui_core/accumulators/object_funds_checker/mod.rs
1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5 collections::{BTreeMap, BTreeSet},
6 sync::Arc,
7};
8
9use mysten_common::{assert_reachable, debug_fatal};
10use sui_types::{
11 accumulator_root::AccumulatorObjId,
12 base_types::SequenceNumber,
13 effects::{TransactionEffects, TransactionEffectsAPI},
14 executable_transaction::VerifiedExecutableTransaction,
15 execution_params::FundsWithdrawStatus,
16 transaction::TransactionDataAPI,
17};
18use tokio::{
19 sync::{oneshot, watch},
20 time::Instant,
21};
22use tracing::{debug, instrument};
23
24use crate::{
25 accumulators::{
26 funds_read::AccountFundsRead, unsettled_object_withdrawals::UnsettledObjectWithdrawals,
27 },
28 authority::{ExecutionEnv, authority_per_epoch_store::AuthorityPerEpochStore},
29 execution_scheduler::ExecutionScheduler,
30};
31
32#[cfg(test)]
33mod integration_tests;
34pub mod metrics;
35#[cfg(test)]
36mod unit_tests;
37
38/// Note that there is no need to have a separate InsufficientFunds variant.
39/// If the funds are insufficient, the execution would still have to abort and rely on
40/// a rescheduling to be able to execute again.
41pub enum ObjectFundsWithdrawStatus {
42 SufficientFunds,
43 // The receiver will be notified when the funds are determined to be sufficient or insufficient.
44 // The bool is true if the funds are sufficient, false if the funds are insufficient.
45 Pending(oneshot::Receiver<FundsWithdrawStatus>),
46}
47
48pub struct ObjectFundsChecker {
49 /// Watchers to keep track the last settled accumulator version.
50 /// This is updated whenever the settlement barrier transaction is executed.
51 last_settled_version_sender: watch::Sender<SequenceNumber>,
52 last_settled_version_receiver: watch::Receiver<SequenceNumber>,
53 unsettled: Arc<UnsettledObjectWithdrawals>,
54 metrics: Arc<metrics::ObjectFundsCheckerMetrics>,
55}
56
57impl ObjectFundsChecker {
58 pub fn new(
59 starting_accumulator_version: SequenceNumber,
60 unsettled: Arc<UnsettledObjectWithdrawals>,
61 metrics: Arc<metrics::ObjectFundsCheckerMetrics>,
62 ) -> Self {
63 let (last_settled_version_sender, last_settled_version_receiver) =
64 watch::channel(starting_accumulator_version);
65 Self {
66 last_settled_version_sender,
67 last_settled_version_receiver,
68 unsettled,
69 metrics,
70 }
71 }
72
73 #[cfg(test)]
74 pub fn new_for_testing(
75 starting_accumulator_version: SequenceNumber,
76 metrics: Arc<metrics::ObjectFundsCheckerMetrics>,
77 ) -> Self {
78 Self::new(
79 starting_accumulator_version,
80 Arc::new(UnsettledObjectWithdrawals::new(metrics.clone())),
81 metrics,
82 )
83 }
84
85 #[cfg(test)]
86 fn unsettled(&self) -> &Arc<UnsettledObjectWithdrawals> {
87 &self.unsettled
88 }
89
90 #[instrument(level = "debug", skip_all, fields(tx_digest = ?certificate.digest()))]
91 pub fn should_commit_object_funds_withdraws(
92 &self,
93 certificate: &VerifiedExecutableTransaction,
94 effects: &TransactionEffects,
95 accumulator_running_max_withdraws: &BTreeMap<AccumulatorObjId, u128>,
96 execution_env: &ExecutionEnv,
97 funds_read: &Arc<dyn AccountFundsRead>,
98 execution_scheduler: &Arc<ExecutionScheduler>,
99 epoch_store: &Arc<AuthorityPerEpochStore>,
100 ) -> bool {
101 if effects.status().is_err() {
102 // This transaction already failed. It does not matter any more
103 // whether it has sufficient object funds or not.
104 return true;
105 }
106 let address_funds_reservations: BTreeSet<_> = certificate
107 .transaction_data()
108 .process_funds_withdrawals_for_execution(epoch_store.get_chain_identifier())
109 .into_keys()
110 .collect();
111 // All withdraws will show up as accumulator events with integer values.
112 // Among them, addresses that do not have funds reservations are object
113 // withdraws.
114 let object_running_max_withdraws: BTreeMap<_, _> = accumulator_running_max_withdraws
115 .clone()
116 .into_iter()
117 .filter(|(account, _)| !address_funds_reservations.contains(account))
118 .collect();
119 // If there are no object withdraws, we can skip checking object funds.
120 if object_running_max_withdraws.is_empty() {
121 return true;
122 }
123 // A tx with object withdraws can only exist when accumulators are enabled
124 // for the epoch, and every production path that produces such a tx also
125 // assigns an accumulator version. The `None` paths (accumulator-disabled
126 // epoch, end-of-epoch tx) never produce withdraws and so never reach here.
127 let Some(accumulator_version) = execution_env.assigned_versions.accumulator_version()
128 else {
129 debug_fatal!("accumulator_version must be set for a tx with object withdraws");
130 return false;
131 };
132 // The sufficiency check must use the running max withdraws (the peak withdraw
133 // exposure at any point during execution), but the amount that settlement will
134 // actually deduct from each account is the net amount recorded in the effects.
135 // E.g. a tx that withdraws 10 and deposits 10 back has a running max of 10 but
136 // nets to 0. Recording the running max as unsettled would over-count against
137 // other withdraws in the same consensus commit.
138 let unsettled_withdraw_updates = if epoch_store
139 .protocol_config()
140 .record_net_unsettled_object_withdraws()
141 {
142 let updates: BTreeMap<_, _> = effects
143 .accumulator_events()
144 .into_iter()
145 .filter(|event| !address_funds_reservations.contains(&event.accumulator_obj))
146 .filter_map(|event| {
147 event
148 .write
149 .get_fund_withdraw_amount()
150 // A zero-amount withdraw emits a single Split(0) accumulator event,
151 // which survives effects folding as a Split (the fold's Merge
152 // tie-break only applies when an account has multiple writes).
153 // It contributes nothing to the running max nor to settlement,
154 // so recording it would be a no-op; skip it.
155 .filter(|amount| *amount > 0)
156 .map(|amount| (event.accumulator_obj, amount))
157 })
158 .collect();
159 // A positive net withdraw in effects implies a positive peak, so the account
160 // must have a running max entry that the net cannot exceed. Recording more
161 // than what the sufficiency check covered could break the
162 // funds >= unsettled_withdraw invariant in try_withdraw.
163 debug_assert!(
164 updates.iter().all(|(obj_id, net)| {
165 object_running_max_withdraws
166 .get(obj_id)
167 .is_some_and(|max| net <= max)
168 }),
169 "net withdraw exceeds running max: tx={:?} updates={:?} running_max={:?}",
170 certificate.digest(),
171 updates,
172 object_running_max_withdraws,
173 );
174 updates
175 } else {
176 object_running_max_withdraws.clone()
177 };
178 match self.check_object_funds(
179 object_running_max_withdraws,
180 unsettled_withdraw_updates,
181 accumulator_version,
182 funds_read.as_ref(),
183 ) {
184 // Sufficient funds, we can go ahead and commit the execution results as it is.
185 ObjectFundsWithdrawStatus::SufficientFunds => {
186 assert_reachable!("object funds sufficient");
187 debug!("Object funds sufficient, committing effects");
188 self.metrics
189 .check_result
190 .with_label_values(&["sufficient"])
191 .inc();
192 true
193 }
194 // Currently insufficient funds. We need to wait until it reach a deterministic state
195 // before we can determine if it is really insufficient (to include potential deposits)
196 // At that time we will have to re-enqueue the transaction for execution again.
197 // Re-enqueue is handled here so the caller does not need to worry about it.
198 ObjectFundsWithdrawStatus::Pending(receiver) => {
199 self.metrics.pending_checks.inc();
200 let timer = self.metrics.pending_check_latency.start_timer();
201 let pending_metrics = self.metrics.clone();
202 let scheduler = execution_scheduler.clone();
203 let cert = certificate.clone();
204 let mut execution_env = execution_env.clone();
205 let epoch_store = epoch_store.clone();
206 tokio::task::spawn(async move {
207 // It is possible that checkpoint executor finished executing
208 // the current epoch and went ahead with epoch change asynchronously,
209 // while this is still waiting.
210 let inner_metrics = pending_metrics.clone();
211 let _ = epoch_store
212 .within_alive_epoch(async move {
213 let tx_digest = cert.digest();
214 match receiver.await {
215 Ok(FundsWithdrawStatus::MaybeSufficient) => {
216 assert_reachable!("object funds maybe sufficient");
217 // The withdraw state is now deterministically known,
218 // so we can enqueue the transaction again and it will check again
219 // whether it is sufficient or not in the next execution.
220 // TODO: We should be able to optimize this by avoiding re-execution.
221 debug!(?tx_digest, "Object funds possibly sufficient");
222 }
223 Ok(FundsWithdrawStatus::Insufficient) => {
224 assert_reachable!("object funds insufficient");
225 // Re-enqueue with insufficient funds status, so it will be executed
226 // in the next execution and fail through early error.
227 // FIXME: We need to also track the amount of gas that was used,
228 // so that we could charge properly in the next execution when we
229 // go through early error. Otherwise we would undercharge.
230 execution_env = execution_env.with_insufficient_funds();
231 inner_metrics
232 .check_result
233 .with_label_values(&["insufficient"])
234 .inc();
235 debug!(?tx_digest, "Object funds insufficient");
236 }
237 Err(e) => {
238 tracing::error!(
239 "Error receiving funds withdraw status: {:?}",
240 e
241 );
242 }
243 }
244 scheduler.send_transaction_for_execution(
245 &cert,
246 execution_env,
247 // TODO: Should the enqueue_time be the original enqueue time
248 // of this transaction?
249 Instant::now(),
250 );
251 })
252 .await;
253 timer.observe_duration();
254 pending_metrics.pending_checks.dec();
255 });
256 false
257 }
258 }
259 }
260
261 fn check_object_funds(
262 &self,
263 object_running_max_withdraws: BTreeMap<AccumulatorObjId, u128>,
264 unsettled_withdraw_updates: BTreeMap<AccumulatorObjId, u128>,
265 accumulator_version: SequenceNumber,
266 funds_read: &dyn AccountFundsRead,
267 ) -> ObjectFundsWithdrawStatus {
268 let last_settled_version = *self.last_settled_version_receiver.borrow();
269 if accumulator_version <= last_settled_version {
270 // If the version we are withdrawing from is already settled, we have all the information
271 // we need to determine if the funds are sufficient or not.
272 if self.try_withdraw(
273 funds_read,
274 &object_running_max_withdraws,
275 &unsettled_withdraw_updates,
276 accumulator_version,
277 ) {
278 return ObjectFundsWithdrawStatus::SufficientFunds;
279 } else {
280 let (sender, receiver) = oneshot::channel();
281 // unwrap is safe because the receiver is defined right above.
282 sender.send(FundsWithdrawStatus::Insufficient).unwrap();
283 return ObjectFundsWithdrawStatus::Pending(receiver);
284 }
285 }
286
287 // Spawn a task to wait for the last settled version to become accumulator_version,
288 // before we could check again.
289 let last_settled_version_sender = self.last_settled_version_sender.clone();
290 let (sender, receiver) = oneshot::channel();
291 tokio::spawn(async move {
292 let mut version_receiver = last_settled_version_sender.subscribe();
293 // The wait is guaranteed to be notified because we update version after executing each settlement transaction,
294 // and every settlement transaction must eventually be executed.
295 let res = version_receiver
296 .wait_for(|v| *v >= accumulator_version)
297 .await;
298 if res.is_err() {
299 // This shouldn't happen, but just to be safe.
300 tracing::error!("Last settled accumulator version receiver channel closed");
301 return;
302 }
303 // We notify the waiter that the funds are now deterministically known,
304 // but we don't need to check here whether they are sufficient or not.
305 // Next time during execution we will check again.
306 let _ = sender.send(FundsWithdrawStatus::MaybeSufficient);
307 });
308 ObjectFundsWithdrawStatus::Pending(receiver)
309 }
310
311 /// Checks that each account can cover its running max withdraw (`object_running_max_withdraws`),
312 /// and if so, adds `unsettled_withdraw_updates` to the unsettled withdraws of each account.
313 fn try_withdraw(
314 &self,
315 funds_read: &dyn AccountFundsRead,
316 object_running_max_withdraws: &BTreeMap<AccumulatorObjId, u128>,
317 unsettled_withdraw_updates: &BTreeMap<AccumulatorObjId, u128>,
318 accumulator_version: SequenceNumber,
319 ) -> bool {
320 for (obj_id, amount) in object_running_max_withdraws {
321 let funds = funds_read.get_account_amount_at_version(obj_id, accumulator_version);
322 // Reading unsettled without a top-level lock is safe because no two transactions can be withdrawing
323 // from the same account at the same time.
324 let unsettled_withdraw = self
325 .unsettled
326 .get_unsettled_object_withdraw(obj_id, accumulator_version);
327 debug!(
328 ?obj_id,
329 ?funds,
330 ?accumulator_version,
331 ?unsettled_withdraw,
332 ?amount,
333 "Trying to withdraw"
334 );
335 assert!(funds >= unsettled_withdraw);
336 if funds - unsettled_withdraw < *amount {
337 return false;
338 }
339 }
340 self.unsettled
341 .record_unsettled_withdraws(unsettled_withdraw_updates.iter(), accumulator_version);
342 true
343 }
344
345 pub fn settle_accumulator_version(&self, next_accumulator_version: SequenceNumber) {
346 // unwrap is safe because a receiver is always alive as part of self.
347 self.last_settled_version_sender
348 .send(next_accumulator_version)
349 .unwrap();
350 self.metrics
351 .highest_settled_version
352 .set(next_accumulator_version.value() as i64);
353 }
354
355 #[cfg(test)]
356 pub fn get_current_accumulator_version(&self) -> SequenceNumber {
357 *self.last_settled_version_receiver.borrow()
358 }
359}