Skip to content

Commit 73d02dc

Browse files
afckclaude
andauthored
Only put _fast_ pending blocks in the wallet. (linera-io#6166)
## Motivation Storing the pending block in the wallet is mainly important for _fast_ blocks, where liveness depends on the client not making any conflicting proposal. Blocks proposals can also quite large, and take up a lot of space in the wallet. ## Proposal Only persist _fast_ proposals. ## Test Plan Tests were added to `linera-client`. ## Release Plan - Backport to `testnet_conway`. - Release SDK. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2119f4f commit 73d02dc

11 files changed

Lines changed: 197 additions & 17 deletions

File tree

linera-client/src/chain_listener.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ pub trait ClientContext {
109109
chain_id,
110110
chain.block_hash,
111111
chain.next_block_height,
112-
&chain.pending_proposal,
112+
&chain.pending_fast_proposal,
113113
chain.owner,
114114
self.timing_sender(),
115115
follow_only,

linera-client/src/client_context.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,14 @@ impl<Env: Environment> ClientContext<Env> {
430430
.map_err(error::Inner::wallet)?
431431
.and_then(|chain| chain.owner);
432432

433+
// Only persist proposals that were made in the fast round: they need to be
434+
// remembered across sessions to make sure there are no conflicting fast proposals.
435+
let pending_fast_proposal = client
436+
.pending_proposal()
437+
.await
438+
.filter(|p| p.round.is_some_and(|r| r.is_fast()));
433439
let new_chain = wallet::Chain {
434-
pending_proposal: client.pending_proposal().await,
440+
pending_fast_proposal,
435441
owner: existing_owner,
436442
..info.as_ref().into()
437443
};
@@ -1015,12 +1021,15 @@ impl<Env: Environment> ClientContext<Env> {
10151021
for chain_client in chain_clients {
10161022
let info = chain_client.chain_info().await?;
10171023
let client_owner = chain_client.preferred_owner();
1018-
let pending_proposal = chain_client.pending_proposal().await;
1024+
let pending_fast_proposal = chain_client
1025+
.pending_proposal()
1026+
.await
1027+
.filter(|p| p.round.is_some_and(|r| r.is_fast()));
10191028
self.wallet()
10201029
.insert(
10211030
info.chain_id,
10221031
wallet::Chain {
1023-
pending_proposal,
1032+
pending_fast_proposal,
10241033
owner: client_owner,
10251034
..info.as_ref().into()
10261035
},

linera-client/src/unit_tests/chain_listener.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,14 @@ impl chain_listener::ClientContext for ClientContext {
7070
) -> Result<(), Error> {
7171
let info = client.chain_info().await?;
7272
let existing_owner = self.wallet().get(info.chain_id).and_then(|c| c.owner);
73-
let pending_proposal = client.pending_proposal().await;
73+
let pending_fast_proposal = client
74+
.pending_proposal()
75+
.await
76+
.filter(|p| p.round.is_some_and(|r| r.is_fast()));
7477
self.wallet().insert(
7578
info.chain_id,
7679
wallet::Chain {
77-
pending_proposal,
80+
pending_fast_proposal,
7881
owner: existing_owner,
7982
..info.as_ref().into()
8083
},
@@ -249,7 +252,7 @@ async fn test_chain_listener_follow_only() -> anyhow::Result<()> {
249252
block_hash: chain_a_info.block_hash,
250253
next_block_height: chain_a_info.next_block_height,
251254
timestamp: clock.current_time(),
252-
pending_proposal: None,
255+
pending_fast_proposal: None,
253256
epoch: Some(chain_a_info.epoch),
254257
},
255258
);
@@ -262,7 +265,7 @@ async fn test_chain_listener_follow_only() -> anyhow::Result<()> {
262265
block_hash: chain_b_info.block_hash,
263266
next_block_height: chain_b_info.next_block_height,
264267
timestamp: clock.current_time(),
265-
pending_proposal: None,
268+
pending_fast_proposal: None,
266269
epoch: Some(chain_b_info.epoch),
267270
},
268271
);
@@ -600,7 +603,7 @@ async fn test_listener_uses_autosigner_for_incoming_messages() -> anyhow::Result
600603
block_hash: chain0_info.block_hash,
601604
next_block_height: chain0_info.next_block_height,
602605
timestamp: clock.current_time(),
603-
pending_proposal: None,
606+
pending_fast_proposal: None,
604607
epoch: Some(chain0_info.epoch),
605608
},
606609
);
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) Zefchain Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Tests for [`ClientContext::update_wallet_from_client`].
5+
6+
use std::{
7+
collections::{BTreeMap, BTreeSet, HashSet},
8+
sync::Arc,
9+
time::Duration,
10+
};
11+
12+
use linera_base::{
13+
crypto::InMemorySigner,
14+
data_types::{Amount, Round, TimeDelta},
15+
identifiers::{AccountOwner, ChainId},
16+
ownership::{ChainOwnership, TimeoutConfig},
17+
};
18+
use linera_core::{
19+
client::{chain_client, Client, ListeningMode},
20+
environment,
21+
join_set_ext::JoinSet,
22+
test_utils::{FaultType, MemoryStorageBuilder, TestBuilder},
23+
worker::{DEFAULT_BLOCK_CACHE_SIZE, DEFAULT_EXECUTION_STATE_CACHE_SIZE},
24+
};
25+
use linera_rpc::node_provider::DEFAULT_MAX_BACKOFF;
26+
27+
use crate::{client_context::ClientContext, config::GenesisConfig};
28+
29+
/// Builds a production [`ClientContext`] with a fresh in-memory wallet, sharing validators
30+
/// with the given [`TestBuilder`].
31+
async fn make_context(
32+
builder: &mut TestBuilder<MemoryStorageBuilder>,
33+
signer: InMemorySigner,
34+
chain_id: ChainId,
35+
) -> anyhow::Result<ClientContext<environment::Test>> {
36+
let genesis_config = GenesisConfig::new_for_testing(builder);
37+
let admin_chain_id = genesis_config.admin_chain_id();
38+
let storage = builder.make_storage().await?;
39+
let client = Client::new(
40+
environment::Impl {
41+
storage,
42+
network: builder.make_node_provider(),
43+
signer,
44+
wallet: environment::TestWallet::default(),
45+
},
46+
admin_chain_id,
47+
false,
48+
[(chain_id, ListeningMode::FullChain)],
49+
format!("Client node for {:.8}", chain_id),
50+
Some(Duration::from_secs(30)),
51+
Some(Duration::from_secs(1)),
52+
HashSet::new(),
53+
chain_client::Options::test_default(),
54+
DEFAULT_BLOCK_CACHE_SIZE,
55+
DEFAULT_EXECUTION_STATE_CACHE_SIZE,
56+
&linera_core::client::RequestsSchedulerConfig::default(),
57+
);
58+
Ok(ClientContext {
59+
client: Arc::new(client),
60+
genesis_config,
61+
send_timeout: Duration::from_secs(4),
62+
recv_timeout: Duration::from_secs(4),
63+
retry_delay: Duration::from_secs(1),
64+
max_retries: 10,
65+
max_backoff: DEFAULT_MAX_BACKOFF,
66+
chain_listeners: JoinSet::default(),
67+
default_chain: None,
68+
client_metrics: None,
69+
})
70+
}
71+
72+
/// A fast-round pending proposal must be persisted in the wallet.
73+
#[test_log::test(tokio::test)]
74+
async fn test_wallet_persists_fast_pending_proposal() -> anyhow::Result<()> {
75+
let signer = InMemorySigner::new(None);
76+
let mut builder =
77+
TestBuilder::new(MemoryStorageBuilder::default(), 4, 0, signer.clone()).await?;
78+
let mut client = builder.add_root_chain(1, Amount::from_tokens(10)).await?;
79+
client.options_mut().allow_fast_blocks = true;
80+
let chain_id = client.chain_id();
81+
let owner = client.identity().await?;
82+
83+
let timeout_config = TimeoutConfig {
84+
fast_round_duration: Some(TimeDelta::from_secs(5)),
85+
..TimeoutConfig::default()
86+
};
87+
let ownership = ChainOwnership {
88+
super_owners: BTreeSet::from_iter([owner]),
89+
owners: BTreeMap::default(),
90+
first_leader: None,
91+
multi_leader_rounds: 10,
92+
open_multi_leader_rounds: false,
93+
timeout_config,
94+
};
95+
client.change_ownership(ownership).await?;
96+
97+
// Three offline validators make the burn fail; the proposal stays pending.
98+
builder.set_fault_type([1, 2, 3], FaultType::OfflineWithInfo);
99+
assert!(client
100+
.burn(AccountOwner::CHAIN, Amount::from_tokens(3))
101+
.await
102+
.is_err());
103+
let pending = client
104+
.pending_proposal()
105+
.await
106+
.expect("expected fast pending proposal after failed burn");
107+
assert_eq!(pending.round, Some(Round::Fast));
108+
109+
let context = make_context(&mut builder, signer, chain_id).await?;
110+
context.update_wallet_from_client(&client).await?;
111+
let stored = context
112+
.wallet()
113+
.get(chain_id)
114+
.expect("wallet missing chain entry")
115+
.pending_fast_proposal
116+
.expect("wallet missing fast pending proposal");
117+
assert_eq!(stored.round, Some(Round::Fast));
118+
Ok(())
119+
}
120+
121+
/// A non-fast pending proposal must NOT be persisted in the wallet.
122+
#[test_log::test(tokio::test)]
123+
async fn test_wallet_drops_non_fast_pending_proposal() -> anyhow::Result<()> {
124+
let mut signer = InMemorySigner::new(None);
125+
let mut builder =
126+
TestBuilder::new(MemoryStorageBuilder::default(), 4, 0, signer.clone()).await?;
127+
let client = builder.add_root_chain(1, Amount::from_tokens(10)).await?;
128+
let chain_id = client.chain_id();
129+
let owner0 = client.identity().await?;
130+
let owner1: AccountOwner = signer.generate_new().into();
131+
132+
let timeout_config = TimeoutConfig {
133+
fast_round_duration: Some(TimeDelta::from_secs(5)),
134+
..TimeoutConfig::default()
135+
};
136+
let ownership = ChainOwnership::multiple([(owner0, 100), (owner1, 100)], 10, timeout_config);
137+
client.change_ownership(ownership).await?;
138+
139+
// Three offline validators make the burn fail; the proposal stays pending.
140+
builder.set_fault_type([1, 2, 3], FaultType::OfflineWithInfo);
141+
assert!(client
142+
.burn(AccountOwner::CHAIN, Amount::from_tokens(3))
143+
.await
144+
.is_err());
145+
let pending = client
146+
.pending_proposal()
147+
.await
148+
.expect("expected non-fast pending proposal after failed burn");
149+
assert_eq!(pending.round, Some(Round::MultiLeader(0)));
150+
151+
let context = make_context(&mut builder, signer, chain_id).await?;
152+
context.update_wallet_from_client(&client).await?;
153+
let stored = context
154+
.wallet()
155+
.get(chain_id)
156+
.expect("wallet missing chain entry");
157+
assert!(stored.pending_fast_proposal.is_none());
158+
Ok(())
159+
}

linera-client/src/unit_tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
mod chain_listener;
5+
mod client_context;

linera-core/src/client/chain_client/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,7 @@ impl<Env: Environment> ChainClient<Env> {
15191519
block: proposed_block,
15201520
blobs,
15211521
auto_retry_outcome: Some(auto_retry_outcome),
1522+
round: None,
15221523
});
15231524
Ok(block)
15241525
}
@@ -1973,6 +1974,9 @@ impl<Env: Environment> ChainClient<Env> {
19731974
Either::Right(timeout) => return Ok(ClientOutcome::WaitForTimeout(timeout)),
19741975
};
19751976
debug!("Proposing block for round {}", round);
1977+
if let Some(pending) = proposal_guard.as_mut() {
1978+
pending.round.get_or_insert(round);
1979+
}
19761980

19771981
let already_handled_locally = info
19781982
.manager

linera-core/src/client/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use linera_base::prometheus_util::MeasureLatency as _;
1919
use linera_base::{
2020
crypto::{CryptoHash, Signer as _, ValidatorPublicKey},
2121
data_types::{
22-
ArithmeticError, Blob, BlockHeight, ChainDescription, Epoch, TimeDelta, Timestamp,
22+
ArithmeticError, Blob, BlockHeight, ChainDescription, Epoch, Round, TimeDelta, Timestamp,
2323
},
2424
ensure,
2525
identifiers::{AccountOwner, BlobId, BlobType, ChainId, EventId, StreamId},
@@ -2061,6 +2061,9 @@ pub struct PendingProposal {
20612061
/// against the committed execution outcome.
20622062
#[serde(default)]
20632063
pub auto_retry_outcome: Option<BlockExecutionOutcome>,
2064+
/// The round in which this proposal was first submitted, if any.
2065+
#[serde(default)]
2066+
pub round: Option<Round>,
20642067
}
20652068

20662069
enum ReceiveCertificateMode {

linera-core/src/environment/wallet/memory.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ mod tests {
140140
block_hash: None,
141141
next_block_height: height.into(),
142142
timestamp: Timestamp::from(0),
143-
pending_proposal: None,
143+
pending_fast_proposal: None,
144144
epoch: None,
145145
}
146146
}

linera-core/src/environment/wallet/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub struct Chain {
2121
pub block_hash: Option<CryptoHash>,
2222
pub next_block_height: BlockHeight,
2323
pub timestamp: Timestamp,
24-
pub pending_proposal: Option<PendingProposal>,
24+
pub pending_fast_proposal: Option<PendingProposal>,
2525
pub epoch: Option<Epoch>,
2626
}
2727

@@ -32,7 +32,7 @@ impl From<&ChainInfo> for Chain {
3232
block_hash: info.block_hash,
3333
next_block_height: info.next_block_height,
3434
timestamp: info.timestamp,
35-
pending_proposal: None,
35+
pending_fast_proposal: None,
3636
epoch: Some(info.epoch),
3737
}
3838
}
@@ -64,7 +64,7 @@ impl Chain {
6464
block_hash: None,
6565
timestamp: now,
6666
next_block_height: BlockHeight::ZERO,
67-
pending_proposal: None,
67+
pending_fast_proposal: None,
6868
epoch: Some(current_epoch),
6969
}
7070
}

linera-service/tests/wallet.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async fn test_save_wallet_with_pending_blobs() -> anyhow::Result<()> {
118118
&wallet::Chain {
119119
owner: Some(new_pubkey.into()),
120120
timestamp: clock.current_time(),
121-
pending_proposal: Some(PendingProposal {
121+
pending_fast_proposal: Some(PendingProposal {
122122
block: ProposedBlock {
123123
chain_id,
124124
epoch: Epoch::ZERO,
@@ -130,6 +130,7 @@ async fn test_save_wallet_with_pending_blobs() -> anyhow::Result<()> {
130130
},
131131
blobs: vec![Blob::new_data(b"blob".to_vec())],
132132
auto_retry_outcome: None,
133+
round: None,
133134
}),
134135
..admin_description.into()
135136
},

0 commit comments

Comments
 (0)