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;
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.
54fn 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
69/// Whether a channel can still be used (active and not fully spent).
70fn 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
79/// Convert a persisted `Channel` to a `ChannelEntry`.
80pub 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
96/// Create a `Channel` from a `ChannelEntry` with metadata.
97pub 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
131/// Load channels from database, evicting spent/inactive entries.
132pub 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
158/// Save channels to database.
159pub 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
172/// Delete a channel from the database by its channel ID.
173pub 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
182/// Look up a usable persisted channel by key.
183pub 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
187/// Insert or update a channel entry in memory only (no DB write).
188pub 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}