Skip to main content

foundry_common/provider/mpp/
persist.rs

1//! Persistent channel storage for MPP sessions.
2//!
3//! Stores open payment channel state in a SQLite database at
4//! `$TEMPO_HOME/channels.db` (default: `~/.tempo/channels.db`).
5//! This allows channel reuse across process invocations, avoiding the cost of
6//! opening a new on-chain channel for every `cast` / `forge` command.
7
8use 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
20/// Process-wide database handle.
21fn 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
51/// Reconstruct the composite HashMap key from a persisted `Channel`.
52///
53/// Mirrors `SessionProvider::channel_key()` in session.rs. The persisted
54/// schema has no `operator` column, so we always emit `Address::ZERO` as the
55/// operator component — correct for legacy escrow rows.
56fn 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
72/// Precompile channels aren't persisted: the `Channel` schema has no column for
73/// their `operator`, so they'd reload under a wrong `operator=ZERO` key.
74fn is_precompile_channel(ch: &Channel) -> bool {
75    matches!(ch.escrow_contract.parse::<Address>(), Ok(addr) if is_precompile_escrow(addr))
76}
77
78/// Whether a channel can still be used (active and not fully spent).
79fn 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
88/// Convert a persisted `Channel` to a `ChannelEntry`.
89pub 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
105/// Create a `Channel` from a `ChannelEntry` with metadata.
106pub 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
140/// Load channels from database, evicting spent/inactive entries.
141pub 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
167/// Save channels to database.
168pub 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
184/// Delete a channel from the database by its channel ID.
185pub 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
194/// Look up a usable persisted channel by key.
195pub 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
199/// Insert or update a channel entry in memory only (no DB write).
200pub 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    /// Persisted key must match the 8-field runtime shape (operator=ZERO).
291    #[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}