foundry_common/provider/mpp/
persist.rs1use alloy_primitives::{Address, B256};
9use foundry_wallets::{Channel, ChannelDb};
10use mpp::client::channel_ops::ChannelEntry;
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 {
55 let origin_hash = &alloy_primitives::keccak256(ch.origin.as_bytes()).to_string()[..18];
56 format!(
57 "{}:{}:{}:{}:{}:{}:{}",
58 origin_hash,
59 ch.chain_id,
60 ch.payer,
61 ch.authorized_signer,
62 ch.payee,
63 ch.token,
64 ch.escrow_contract
65 )
66 .to_lowercase()
67}
68
69fn is_usable(ch: &Channel) -> bool {
71 if ch.state != "active" {
72 return false;
73 }
74 let cumulative: u128 = ch.cumulative_amount.parse().unwrap_or(u128::MAX);
75 let deposit: u128 = ch.deposit.parse().unwrap_or(0);
76 cumulative < deposit
77}
78
79pub fn to_channel_entry(ch: &Channel) -> Option<ChannelEntry> {
81 let channel_id: B256 = ch.channel_id.parse().ok()?;
82 let salt: B256 = ch.salt.parse().ok()?;
83 let escrow_contract: Address = ch.escrow_contract.parse().ok()?;
84 let cumulative_amount: u128 = ch.cumulative_amount.parse().ok()?;
85
86 Some(ChannelEntry {
87 channel_id,
88 salt,
89 cumulative_amount,
90 escrow_contract,
91 chain_id: ch.chain_id as u64,
92 opened: ch.state == "active",
93 })
94}
95
96pub fn from_channel_entry(
98 entry: &ChannelEntry,
99 deposit: u128,
100 origin: &str,
101 payer: &Address,
102 payee: &Address,
103 token: &Address,
104 authorized_signer: &Address,
105) -> Channel {
106 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
107
108 Channel {
109 channel_id: entry.channel_id.to_string(),
110 version: 1,
111 origin: origin.to_string(),
112 request_url: String::new(),
113 chain_id: entry.chain_id as i64,
114 escrow_contract: entry.escrow_contract.to_string(),
115 token: token.to_string(),
116 payee: payee.to_string(),
117 payer: payer.to_string(),
118 authorized_signer: authorized_signer.to_string(),
119 salt: entry.salt.to_string(),
120 deposit: deposit.to_string(),
121 cumulative_amount: entry.cumulative_amount.to_string(),
122 challenge_echo: String::new(),
123 state: if entry.opened { "active" } else { "closed" }.to_string(),
124 close_requested_at: 0,
125 grace_ready_at: 0,
126 created_at: now,
127 last_used_at: now,
128 }
129}
130
131pub fn load_channels() -> HashMap<String, Channel> {
133 let Some(db) = global_db() else {
134 return HashMap::new();
135 };
136
137 let channels = match db.load() {
138 Ok(channels) => channels,
139 Err(e) => {
140 warn!(%e, "failed to load channels from database");
141 return HashMap::new();
142 }
143 };
144
145 let usable: HashMap<String, Channel> = channels
146 .into_iter()
147 .filter(is_usable)
148 .map(|ch| {
149 let key = channel_key_from_persisted(&ch);
150 (key, ch)
151 })
152 .collect();
153
154 debug!(count = usable.len(), "loaded persisted MPP channels");
155 usable
156}
157
158pub fn save_channels(channels: &HashMap<String, Channel>) {
160 let Some(db) = global_db() else {
161 return;
162 };
163
164 for ch in channels.values() {
165 if let Err(e) = db.upsert(ch) {
166 warn!(%e, channel_id = %ch.channel_id, "failed to save channel");
167 }
168 }
169 debug!(count = channels.len(), "saved MPP channels");
170}
171
172pub fn delete_channel_from_db(channel_id: &str) {
174 let Some(db) = global_db() else {
175 return;
176 };
177 if let Err(e) = db.delete(channel_id) {
178 warn!(%e, channel_id, "failed to delete channel from database");
179 }
180}
181
182pub fn find_channel(channels: &HashMap<String, Channel>, key: &str) -> Option<ChannelEntry> {
184 channels.get(key).filter(|ch| is_usable(ch)).and_then(to_channel_entry)
185}
186
187pub fn upsert_channel_in_memory(
189 channels: &mut HashMap<String, Channel>,
190 key: &str,
191 entry: &ChannelEntry,
192) {
193 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
194
195 if let Some(existing) = channels.get_mut(key) {
196 existing.cumulative_amount = entry.cumulative_amount.to_string();
197 existing.last_used_at = now;
198 existing.state = if entry.opened { "active" } else { "closed" }.to_string();
199 } else {
200 warn!(key, "upsert_channel_in_memory called for unknown channel");
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn test_channel(state: &str, cumulative: &str, deposit: &str) -> Channel {
209 Channel {
210 channel_id: format!("0x{}", "ab".repeat(32)),
211 version: 1,
212 origin: "https://rpc.mpp.moderato.tempo.xyz".to_string(),
213 request_url: String::new(),
214 chain_id: 42431,
215 escrow_contract: "0xe1c4d3dce17bc111181ddf716f75bae49e61a336".to_string(),
216 token: "0x20c0000000000000000000000000000000000000".to_string(),
217 payee: "0x3333333333333333333333333333333333333333".to_string(),
218 payer: "0x1111111111111111111111111111111111111111".to_string(),
219 authorized_signer: "0x1111111111111111111111111111111111111111".to_string(),
220 salt: format!("0x{}", "cd".repeat(32)),
221 deposit: deposit.to_string(),
222 cumulative_amount: cumulative.to_string(),
223 challenge_echo: String::new(),
224 state: state.to_string(),
225 close_requested_at: 0,
226 grace_ready_at: 0,
227 created_at: 1000,
228 last_used_at: 1000,
229 }
230 }
231
232 #[test]
233 fn usable() {
234 assert!(is_usable(&test_channel("active", "5000", "100000")));
235 assert!(!is_usable(&test_channel("active", "100000", "100000")));
236 assert!(!is_usable(&test_channel("active", "200000", "100000")));
237 assert!(!is_usable(&test_channel("closed", "0", "100000")));
238 assert!(!is_usable(&test_channel("closing", "0", "100000")));
239 }
240
241 #[test]
242 fn channel_entry_round_trip() {
243 let entry = ChannelEntry {
244 channel_id: B256::random(),
245 salt: B256::random(),
246 cumulative_amount: 42000,
247 escrow_contract: Address::random(),
248 chain_id: 42431,
249 opened: true,
250 };
251
252 let payer = Address::random();
253 let payee = Address::random();
254 let token = Address::random();
255 let persisted =
256 from_channel_entry(&entry, 100_000, "https://rpc.test", &payer, &payee, &token, &payer);
257 let restored = to_channel_entry(&persisted).expect("should parse back");
258
259 assert_eq!(restored.channel_id, entry.channel_id);
260 assert_eq!(restored.salt, entry.salt);
261 assert_eq!(restored.cumulative_amount, entry.cumulative_amount);
262 assert_eq!(restored.escrow_contract, entry.escrow_contract);
263 assert_eq!(restored.chain_id, entry.chain_id);
264 assert!(restored.opened);
265 }
266
267 #[test]
268 fn find_channel_filters_unusable() {
269 let mut channels = HashMap::new();
270 channels.insert("usable".into(), test_channel("active", "1000", "100000"));
271 channels.insert("spent".into(), test_channel("active", "100000", "100000"));
272
273 assert!(find_channel(&channels, "usable").is_some());
274 assert!(find_channel(&channels, "spent").is_none());
275 assert!(find_channel(&channels, "missing").is_none());
276 }
277}