foundry_common/provider/mpp/
persist.rs1use alloy_primitives::{Address, B256};
9use foundry_wallets::{Channel, ChannelDb};
10use mpp::client::channel_ops::{ChannelEntry, is_precompile_escrow};
11use std::{
12 collections::HashMap,
13 sync::OnceLock,
14 time::{SystemTime, UNIX_EPOCH},
15};
16use tracing::{debug, warn};
17
18use crate::tempo::tempo_home;
19
20fn global_db() -> Option<&'static ChannelDb> {
22 static DB: OnceLock<Option<ChannelDb>> = OnceLock::new();
23 DB.get_or_init(|| {
24 let path = tempo_home()?.join("channels.db");
25 if let Some(parent) = path.parent() {
26 let _ = std::fs::create_dir_all(parent);
27 }
28 if let Some(old) =
29 tempo_home().map(|h| h.join("foundry/channels.json")).filter(|p| p.exists())
30 {
31 warn!(
32 ?old,
33 "found old channels.json — this file is no longer used; channels will be re-opened"
34 );
35 }
36
37 match ChannelDb::open(&path) {
38 Ok(db) => {
39 debug!(?path, "opened channel database");
40 Some(db)
41 }
42 Err(e) => {
43 warn!(?path, %e, "failed to open channel database");
44 None
45 }
46 }
47 })
48 .as_ref()
49}
50
51fn channel_key_from_persisted(ch: &Channel) -> String {
57 let origin_hash = &alloy_primitives::keccak256(ch.origin.as_bytes()).to_string()[..18];
58 format!(
59 "{}:{}:{}:{}:{}:{}:{}:{}",
60 origin_hash,
61 ch.chain_id,
62 ch.payer,
63 ch.authorized_signer,
64 ch.payee,
65 ch.token,
66 ch.escrow_contract,
67 Address::ZERO,
68 )
69 .to_lowercase()
70}
71
72fn is_precompile_channel(ch: &Channel) -> bool {
75 matches!(ch.escrow_contract.parse::<Address>(), Ok(addr) if is_precompile_escrow(addr))
76}
77
78fn is_usable(ch: &Channel) -> bool {
80 if ch.state != "active" {
81 return false;
82 }
83 let cumulative: u128 = ch.cumulative_amount.parse().unwrap_or(u128::MAX);
84 let deposit: u128 = ch.deposit.parse().unwrap_or(0);
85 cumulative < deposit
86}
87
88pub fn to_channel_entry(ch: &Channel) -> Option<ChannelEntry> {
90 let channel_id: B256 = ch.channel_id.parse().ok()?;
91 let salt: B256 = ch.salt.parse().ok()?;
92 let escrow_contract: Address = ch.escrow_contract.parse().ok()?;
93 let cumulative_amount: u128 = ch.cumulative_amount.parse().ok()?;
94
95 Some(ChannelEntry {
96 channel_id,
97 salt,
98 cumulative_amount,
99 escrow_contract,
100 chain_id: ch.chain_id as u64,
101 opened: ch.state == "active",
102 })
103}
104
105pub fn from_channel_entry(
107 entry: &ChannelEntry,
108 deposit: u128,
109 origin: &str,
110 payer: &Address,
111 payee: &Address,
112 token: &Address,
113 authorized_signer: &Address,
114) -> Channel {
115 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
116
117 Channel {
118 channel_id: entry.channel_id.to_string(),
119 version: 1,
120 origin: origin.to_string(),
121 request_url: String::new(),
122 chain_id: entry.chain_id as i64,
123 escrow_contract: entry.escrow_contract.to_string(),
124 token: token.to_string(),
125 payee: payee.to_string(),
126 payer: payer.to_string(),
127 authorized_signer: authorized_signer.to_string(),
128 salt: entry.salt.to_string(),
129 deposit: deposit.to_string(),
130 cumulative_amount: entry.cumulative_amount.to_string(),
131 challenge_echo: String::new(),
132 state: if entry.opened { "active" } else { "closed" }.to_string(),
133 close_requested_at: 0,
134 grace_ready_at: 0,
135 created_at: now,
136 last_used_at: now,
137 }
138}
139
140pub fn load_channels() -> HashMap<String, Channel> {
142 let Some(db) = global_db() else {
143 return HashMap::new();
144 };
145
146 let channels = match db.load() {
147 Ok(channels) => channels,
148 Err(e) => {
149 warn!(%e, "failed to load channels from database");
150 return HashMap::new();
151 }
152 };
153
154 let usable: HashMap<String, Channel> = channels
155 .into_iter()
156 .filter(|ch| is_usable(ch) && !is_precompile_channel(ch))
157 .map(|ch| {
158 let key = channel_key_from_persisted(&ch);
159 (key, ch)
160 })
161 .collect();
162
163 debug!(count = usable.len(), "loaded persisted MPP channels");
164 usable
165}
166
167pub fn save_channels(channels: &HashMap<String, Channel>) {
169 let Some(db) = global_db() else {
170 return;
171 };
172
173 let mut saved = 0;
174 for ch in channels.values().filter(|ch| !is_precompile_channel(ch)) {
175 if let Err(e) = db.upsert(ch) {
176 warn!(%e, channel_id = %ch.channel_id, "failed to save channel");
177 } else {
178 saved += 1;
179 }
180 }
181 debug!(count = saved, "saved MPP channels");
182}
183
184pub fn delete_channel_from_db(channel_id: &str) {
186 let Some(db) = global_db() else {
187 return;
188 };
189 if let Err(e) = db.delete(channel_id) {
190 warn!(%e, channel_id, "failed to delete channel from database");
191 }
192}
193
194pub fn find_channel(channels: &HashMap<String, Channel>, key: &str) -> Option<ChannelEntry> {
196 channels.get(key).filter(|ch| is_usable(ch)).and_then(to_channel_entry)
197}
198
199pub fn upsert_channel_in_memory(
201 channels: &mut HashMap<String, Channel>,
202 key: &str,
203 entry: &ChannelEntry,
204) {
205 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
206
207 if let Some(existing) = channels.get_mut(key) {
208 existing.cumulative_amount = entry.cumulative_amount.to_string();
209 existing.last_used_at = now;
210 existing.state = if entry.opened { "active" } else { "closed" }.to_string();
211 } else {
212 warn!(key, "upsert_channel_in_memory called for unknown channel");
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn test_channel(state: &str, cumulative: &str, deposit: &str) -> Channel {
221 Channel {
222 channel_id: format!("0x{}", "ab".repeat(32)),
223 version: 1,
224 origin: "https://rpc.mpp.moderato.tempo.xyz".to_string(),
225 request_url: String::new(),
226 chain_id: 42431,
227 escrow_contract: "0xe1c4d3dce17bc111181ddf716f75bae49e61a336".to_string(),
228 token: "0x20c0000000000000000000000000000000000000".to_string(),
229 payee: "0x3333333333333333333333333333333333333333".to_string(),
230 payer: "0x1111111111111111111111111111111111111111".to_string(),
231 authorized_signer: "0x1111111111111111111111111111111111111111".to_string(),
232 salt: format!("0x{}", "cd".repeat(32)),
233 deposit: deposit.to_string(),
234 cumulative_amount: cumulative.to_string(),
235 challenge_echo: String::new(),
236 state: state.to_string(),
237 close_requested_at: 0,
238 grace_ready_at: 0,
239 created_at: 1000,
240 last_used_at: 1000,
241 }
242 }
243
244 #[test]
245 fn usable() {
246 assert!(is_usable(&test_channel("active", "5000", "100000")));
247 assert!(!is_usable(&test_channel("active", "100000", "100000")));
248 assert!(!is_usable(&test_channel("active", "200000", "100000")));
249 assert!(!is_usable(&test_channel("closed", "0", "100000")));
250 assert!(!is_usable(&test_channel("closing", "0", "100000")));
251 }
252
253 #[test]
254 fn channel_entry_round_trip() {
255 let entry = ChannelEntry {
256 channel_id: B256::random(),
257 salt: B256::random(),
258 cumulative_amount: 42000,
259 escrow_contract: Address::random(),
260 chain_id: 42431,
261 opened: true,
262 };
263
264 let payer = Address::random();
265 let payee = Address::random();
266 let token = Address::random();
267 let persisted =
268 from_channel_entry(&entry, 100_000, "https://rpc.test", &payer, &payee, &token, &payer);
269 let restored = to_channel_entry(&persisted).expect("should parse back");
270
271 assert_eq!(restored.channel_id, entry.channel_id);
272 assert_eq!(restored.salt, entry.salt);
273 assert_eq!(restored.cumulative_amount, entry.cumulative_amount);
274 assert_eq!(restored.escrow_contract, entry.escrow_contract);
275 assert_eq!(restored.chain_id, entry.chain_id);
276 assert!(restored.opened);
277 }
278
279 #[test]
280 fn find_channel_filters_unusable() {
281 let mut channels = HashMap::new();
282 channels.insert("usable".into(), test_channel("active", "1000", "100000"));
283 channels.insert("spent".into(), test_channel("active", "100000", "100000"));
284
285 assert!(find_channel(&channels, "usable").is_some());
286 assert!(find_channel(&channels, "spent").is_none());
287 assert!(find_channel(&channels, "missing").is_none());
288 }
289
290 #[test]
292 fn persisted_key_matches_runtime_legacy_shape() {
293 let ch = test_channel("active", "1000", "100000");
294 let key = channel_key_from_persisted(&ch);
295
296 assert_eq!(key.split(':').count(), 8);
297 assert!(key.ends_with(":0x0000000000000000000000000000000000000000"));
298 }
299
300 #[test]
301 fn precompile_channels_are_not_persisted() {
302 let mut ch = test_channel("active", "1000", "100000");
303 assert!(!is_precompile_channel(&ch));
304
305 ch.escrow_contract = "0x4D50500000000000000000000000000000000000".to_string();
306 assert!(is_precompile_channel(&ch));
307 }
308}