pub mod channel_ops;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use alloy::primitives::{Address, B256};
use self::channel_ops::{
build_credential, create_close_payload, create_open_payload, create_voucher_payload,
resolve_chain_id, resolve_escrow, try_recover_channel, ChannelEntry, OpenPayloadOptions,
};
use crate::client::PaymentProvider;
use crate::error::{MppError, ResultExt};
use crate::protocol::core::{PaymentChallenge, PaymentCredential, Receipt};
use crate::protocol::intents::SessionRequest;
use crate::protocol::methods::tempo::session::TempoSessionExt;
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct TempoSessionProvider {
signer: Arc<dyn alloy::signers::Signer + Send + Sync>,
rpc_url: reqwest::Url,
escrow_contract: Option<Address>,
authorized_signer: Option<Address>,
signing_mode: crate::client::tempo::signing::TempoSigningMode,
max_deposit: Option<u128>,
default_deposit: Option<u128>,
channels: Arc<Mutex<HashMap<String, ChannelEntry>>>,
channel_id_to_key: Arc<Mutex<HashMap<String, String>>>,
on_channel_update: Option<Arc<dyn Fn(&ChannelEntry) + Send + Sync>>,
last_challenge: Arc<Mutex<Option<PaymentChallenge>>>,
}
impl TempoSessionProvider {
pub fn new(
signer: impl alloy::signers::Signer + Send + Sync + 'static,
rpc_url: impl AsRef<str>,
) -> Result<Self, MppError> {
let url = rpc_url.as_ref().parse().mpp_config("invalid RPC URL")?;
Ok(Self {
signer: Arc::new(signer),
rpc_url: url,
escrow_contract: None,
authorized_signer: None,
signing_mode: crate::client::tempo::signing::TempoSigningMode::Direct,
max_deposit: None,
default_deposit: None,
channels: Arc::new(Mutex::new(HashMap::new())),
channel_id_to_key: Arc::new(Mutex::new(HashMap::new())),
on_channel_update: None,
last_challenge: Arc::new(Mutex::new(None)),
})
}
pub fn with_escrow_contract(mut self, addr: Address) -> Self {
self.escrow_contract = Some(addr);
self
}
pub fn with_authorized_signer(mut self, addr: Address) -> Self {
self.authorized_signer = Some(addr);
self
}
pub fn with_signing_mode(
mut self,
mode: crate::client::tempo::signing::TempoSigningMode,
) -> Self {
self.signing_mode = mode;
self
}
pub fn with_max_deposit(mut self, amount: u128) -> Self {
self.max_deposit = Some(amount);
self
}
pub fn with_default_deposit(mut self, amount: u128) -> Self {
self.default_deposit = Some(amount);
self
}
pub fn with_on_channel_update(
mut self,
callback: impl Fn(&ChannelEntry) + Send + Sync + 'static,
) -> Self {
self.on_channel_update = Some(Arc::new(callback));
self
}
pub fn signer(&self) -> &(dyn alloy::signers::Signer + Send + Sync) {
&*self.signer
}
pub fn rpc_url(&self) -> &reqwest::Url {
&self.rpc_url
}
pub fn channels(&self) -> HashMap<String, ChannelEntry> {
self.channels.lock().unwrap().clone()
}
fn notify_update(&self, entry: &ChannelEntry) {
if let Some(ref cb) = self.on_channel_update {
cb(entry);
}
}
fn channel_key(payee: &Address, currency: &Address, escrow: &Address) -> String {
format!("{:#x}:{:#x}:{:#x}", payee, currency, escrow)
}
pub fn cumulative(&self) -> u128 {
self.channels
.lock()
.unwrap()
.values()
.filter(|e| e.opened)
.map(|e| e.cumulative_amount)
.next()
.unwrap_or(0)
}
pub async fn send_voucher(
&self,
client: &reqwest::Client,
url: &str,
channel_id_hex: &str,
required_cumulative: u128,
) -> Result<(), MppError> {
let challenge =
self.last_challenge.lock().unwrap().clone().ok_or_else(|| {
MppError::InvalidConfig("no challenge available for voucher".into())
})?;
let key = {
let id_map = self.channel_id_to_key.lock().unwrap();
id_map.get(channel_id_hex).cloned()
};
let key = key.ok_or_else(|| {
MppError::InvalidConfig(format!("no channel found for id {}", channel_id_hex))
})?;
let mut entry = {
let channels = self.channels.lock().unwrap();
channels.get(&key).cloned()
}
.ok_or_else(|| MppError::InvalidConfig("channel not found".into()))?;
if required_cumulative > entry.cumulative_amount {
entry.cumulative_amount = required_cumulative;
}
let payload = create_voucher_payload(
self.signer.as_ref(),
entry.channel_id,
entry.cumulative_amount,
entry.escrow_contract,
entry.chain_id,
)
.await?;
self.channels.lock().unwrap().insert(key, entry.clone());
self.notify_update(&entry);
let credential =
build_credential(&challenge, payload, entry.chain_id, self.signer.address());
let auth_header = crate::protocol::core::format_authorization(&credential)?;
let resp = client
.post(url)
.header("Authorization", auth_header)
.send()
.await
.mpp_http("voucher POST failed")?;
if !resp.status().is_success() {
return Err(MppError::Http(format!(
"voucher POST returned status {}",
resp.status()
)));
}
Ok(())
}
pub async fn close(
&self,
client: &reqwest::Client,
url: &str,
) -> Result<Option<Receipt>, MppError> {
let challenge = self.last_challenge.lock().unwrap().clone();
let challenge = match challenge {
Some(c) => c,
None => return Ok(None),
};
let entry = {
let channels = self.channels.lock().unwrap();
channels.values().find(|e| e.opened).cloned()
};
let entry = match entry {
Some(e) => e,
None => return Ok(None),
};
let payer = self.signer.address();
let payload = create_close_payload(
self.signer.as_ref(),
entry.channel_id,
entry.cumulative_amount,
entry.escrow_contract,
entry.chain_id,
)
.await?;
let credential = build_credential(&challenge, payload, entry.chain_id, payer);
let auth_header = crate::protocol::core::format_authorization(&credential)?;
let resp = client
.post(url)
.header("Authorization", auth_header)
.send()
.await
.mpp_http("close request failed")?;
let status = resp.status();
let receipt_header = resp
.headers()
.get("payment-receipt")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(MppError::Http(format!(
"close request returned {}: {}",
status, body
)));
}
let receipt = receipt_header
.as_deref()
.and_then(|s| crate::protocol::core::parse_receipt(s).ok());
Ok(receipt)
}
fn resolve_deposit(&self, suggested_deposit: Option<&str>) -> Result<u128, MppError> {
let suggested = suggested_deposit.and_then(|s| s.parse::<u128>().ok());
match (suggested, self.max_deposit, self.default_deposit) {
(Some(s), Some(max), _) => Ok(s.min(max)),
(Some(s), None, _) => Ok(s),
(None, Some(max), _) => Ok(max),
(None, None, Some(def)) => Ok(def),
(None, None, None) => Err(MppError::InvalidConfig(
"No deposit amount available. Set `default_deposit`, `max_deposit`, or ensure the server challenge includes `suggestedDeposit`.".to_string(),
)),
}
}
}
impl PaymentProvider for TempoSessionProvider {
fn supports(&self, method: &str, intent: &str) -> bool {
method == crate::protocol::methods::tempo::METHOD_NAME
&& intent == crate::protocol::methods::tempo::INTENT_SESSION
}
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
use alloy::providers::ProviderBuilder;
use tempo_alloy::TempoNetwork;
*self.last_challenge.lock().unwrap() = Some(challenge.clone());
let chain_id = resolve_chain_id(challenge);
let escrow_contract = resolve_escrow(challenge, chain_id, self.escrow_contract)?;
let session_req: SessionRequest = challenge
.request
.decode()
.mpp_config("failed to decode session request")?;
let payee: Address = session_req
.recipient
.as_deref()
.ok_or_else(|| {
MppError::InvalidConfig("session challenge missing recipient".to_string())
})?
.parse()
.map_err(|_| MppError::InvalidConfig("invalid recipient address".to_string()))?;
let currency: Address = session_req
.currency
.parse()
.map_err(|_| MppError::InvalidConfig("invalid currency address".to_string()))?;
let amount: u128 = session_req.parse_amount()?;
let payer = self.signing_mode.from_address(self.signer.address());
let key = Self::channel_key(&payee, ¤cy, &escrow_contract);
let existing = self.channels.lock().unwrap().get(&key).cloned();
if let Some(mut entry) = existing {
if entry.opened {
entry.cumulative_amount += amount;
let payload = create_voucher_payload(
self.signer.as_ref(),
entry.channel_id,
entry.cumulative_amount,
escrow_contract,
chain_id,
)
.await?;
self.channels.lock().unwrap().insert(key, entry.clone());
self.notify_update(&entry);
return Ok(build_credential(challenge, payload, chain_id, payer));
}
}
let suggested_channel_id = session_req.channel_id();
if let Some(ref cid_str) = suggested_channel_id {
if let Ok(cid) = cid_str.parse::<B256>() {
let provider = ProviderBuilder::new_with_network::<TempoNetwork>()
.connect_http(self.rpc_url.clone());
if let Some(mut recovered) =
try_recover_channel(&provider, escrow_contract, cid, chain_id).await
{
recovered.cumulative_amount += amount;
let payload = create_voucher_payload(
self.signer.as_ref(),
recovered.channel_id,
recovered.cumulative_amount,
escrow_contract,
chain_id,
)
.await?;
self.channel_id_to_key
.lock()
.unwrap()
.insert(recovered.channel_id.to_string(), key.clone());
self.channels.lock().unwrap().insert(key, recovered.clone());
self.notify_update(&recovered);
return Ok(build_credential(challenge, payload, chain_id, payer));
}
}
}
let deposit = self.resolve_deposit(session_req.suggested_deposit.as_deref())?;
let provider =
ProviderBuilder::new_with_network::<TempoNetwork>().connect_http(self.rpc_url.clone());
let (entry, payload) = create_open_payload(
&provider,
self.signer.as_ref(),
Some(&self.signing_mode),
payer,
OpenPayloadOptions {
authorized_signer: self.authorized_signer,
escrow_contract,
payee,
currency,
deposit,
initial_amount: amount,
chain_id,
fee_payer: session_req.fee_payer(),
},
)
.await?;
self.channel_id_to_key
.lock()
.unwrap()
.insert(entry.channel_id.to_string(), key.clone());
self.channels.lock().unwrap().insert(key, entry.clone());
self.notify_update(&entry);
Ok(build_credential(challenge, payload, chain_id, payer))
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy::signers::local::PrivateKeySigner;
#[test]
fn test_session_provider_new() {
let signer = PrivateKeySigner::random();
let provider =
TempoSessionProvider::new(signer.clone(), "https://rpc.moderato.tempo.xyz").unwrap();
assert_eq!(
provider.rpc_url().as_str(),
"https://rpc.moderato.tempo.xyz/"
);
assert_eq!(provider.signer().address(), signer.address());
}
#[test]
fn test_session_provider_invalid_url() {
let signer = PrivateKeySigner::random();
let result = TempoSessionProvider::new(signer, "not a url");
assert!(result.is_err());
}
#[test]
fn test_session_provider_supports() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert!(provider.supports("tempo", "session"));
assert!(!provider.supports("tempo", "charge"));
assert!(!provider.supports("stripe", "session"));
}
#[test]
fn test_session_provider_builder() {
let signer = PrivateKeySigner::random();
let escrow: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let auth_signer: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_escrow_contract(escrow)
.with_authorized_signer(auth_signer)
.with_max_deposit(1_000_000)
.with_default_deposit(500_000);
assert_eq!(provider.escrow_contract, Some(escrow));
assert_eq!(provider.authorized_signer, Some(auth_signer));
assert_eq!(provider.max_deposit, Some(1_000_000));
assert_eq!(provider.default_deposit, Some(500_000));
}
#[test]
fn test_resolve_deposit_suggested_and_max() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_max_deposit(5000);
assert_eq!(provider.resolve_deposit(Some("3000")).unwrap(), 3000);
assert_eq!(provider.resolve_deposit(Some("8000")).unwrap(), 5000);
}
#[test]
fn test_resolve_deposit_suggested_only() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert_eq!(provider.resolve_deposit(Some("3000")).unwrap(), 3000);
}
#[test]
fn test_resolve_deposit_max_only() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_max_deposit(5000);
assert_eq!(provider.resolve_deposit(None).unwrap(), 5000);
}
#[test]
fn test_resolve_deposit_default_only() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_default_deposit(2000);
assert_eq!(provider.resolve_deposit(None).unwrap(), 2000);
}
#[test]
fn test_resolve_deposit_none() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert!(provider.resolve_deposit(None).is_err());
}
#[test]
fn test_channel_key_format() {
let payee: Address = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
.parse()
.unwrap();
let currency: Address = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
.parse()
.unwrap();
let escrow: Address = "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
.parse()
.unwrap();
let key = TempoSessionProvider::channel_key(&payee, ¤cy, &escrow);
assert_eq!(key, key.to_lowercase());
assert_eq!(key.matches(':').count(), 2);
}
#[test]
fn test_channels_snapshot() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert!(provider.channels().is_empty());
let entry = ChannelEntry {
channel_id: B256::repeat_byte(0xAB),
salt: B256::ZERO,
cumulative_amount: 1000,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: true,
};
provider
.channels
.lock()
.unwrap()
.insert("test-key".to_string(), entry);
assert_eq!(provider.channels().len(), 1);
}
#[test]
fn test_on_channel_update_callback() {
use std::sync::atomic::{AtomicU32, Ordering};
let signer = PrivateKeySigner::random();
let counter = Arc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_on_channel_update(move |_entry| {
counter_clone.fetch_add(1, Ordering::SeqCst);
});
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 0,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: true,
};
provider.notify_update(&entry);
assert_eq!(counter.load(Ordering::SeqCst), 1);
provider.notify_update(&entry);
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[test]
fn test_cumulative_empty_registry() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert_eq!(provider.cumulative(), 0);
}
#[test]
fn test_cumulative_with_opened_channel() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 42_000,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: true,
};
provider
.channels
.lock()
.unwrap()
.insert("key".to_string(), entry);
assert_eq!(provider.cumulative(), 42_000);
}
#[test]
fn test_cumulative_ignores_non_opened_channels() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 99_000,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: false,
};
provider
.channels
.lock()
.unwrap()
.insert("key".to_string(), entry);
assert_eq!(
provider.cumulative(),
0,
"non-opened channels should not be counted"
);
}
#[test]
fn test_session_provider_with_signing_mode() {
use crate::client::tempo::signing::{KeychainVersion, TempoSigningMode};
let signer = PrivateKeySigner::random();
let wallet: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_signing_mode(TempoSigningMode::Keychain {
wallet,
key_authorization: None,
version: KeychainVersion::V1,
});
assert!(matches!(
provider.signing_mode,
TempoSigningMode::Keychain { .. }
));
}
#[test]
fn test_session_provider_default_signing_mode() {
use crate::client::tempo::signing::TempoSigningMode;
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert!(matches!(provider.signing_mode, TempoSigningMode::Direct));
}
#[test]
fn test_resolve_deposit_invalid_suggested_falls_back() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_default_deposit(7000);
assert_eq!(
provider.resolve_deposit(Some("not-a-number")).unwrap(),
7000,
"invalid suggested should fall back to default"
);
}
#[test]
fn test_resolve_deposit_suggested_zero() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
assert_eq!(provider.resolve_deposit(Some("0")).unwrap(), 0);
}
#[test]
fn test_resolve_deposit_suggested_equals_max() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_max_deposit(5000);
assert_eq!(
provider.resolve_deposit(Some("5000")).unwrap(),
5000,
"suggested == max should use that value"
);
}
#[test]
fn test_resolve_deposit_max_and_default_prefers_max() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_max_deposit(5000)
.with_default_deposit(2000);
assert_eq!(provider.resolve_deposit(None).unwrap(), 5000);
}
#[test]
fn test_notify_update_no_callback() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap();
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 0,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: true,
};
provider.notify_update(&entry);
}
#[test]
fn test_session_provider_clone() {
let signer = PrivateKeySigner::random();
let provider = TempoSessionProvider::new(signer, "https://rpc.example.com")
.unwrap()
.with_max_deposit(5000);
let cloned = provider.clone();
assert!(cloned.supports("tempo", "session"));
assert_eq!(cloned.max_deposit, Some(5000));
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 0,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: true,
};
provider
.channels
.lock()
.unwrap()
.insert("key".to_string(), entry);
assert_eq!(cloned.channels().len(), 1);
}
fn make_test_provider() -> TempoSessionProvider {
let signer = PrivateKeySigner::random();
TempoSessionProvider::new(signer, "https://rpc.example.com").unwrap()
}
fn make_channel_entry(channel_id_byte: u8, cumulative: u128, opened: bool) -> ChannelEntry {
ChannelEntry {
channel_id: B256::repeat_byte(channel_id_byte),
salt: B256::ZERO,
cumulative_amount: cumulative,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened,
}
}
fn make_test_challenge() -> PaymentChallenge {
PaymentChallenge::new(
"test-id",
"test-realm",
"tempo",
"session",
crate::protocol::core::Base64UrlJson::from_value(
&serde_json::json!({"amount": "1000"}),
)
.unwrap(),
)
}
#[tokio::test]
async fn test_send_voucher_missing_challenge() {
let provider = make_test_provider();
let client = reqwest::Client::new();
let err = provider
.send_voucher(&client, "https://example.com/pay", "0xdeadbeef", 1000)
.await
.unwrap_err();
assert!(matches!(
err,
MppError::InvalidConfig(ref msg) if msg.contains("no challenge available")
));
}
#[tokio::test]
async fn test_send_voucher_missing_channel_id_mapping() {
let provider = make_test_provider();
*provider.last_challenge.lock().unwrap() = Some(make_test_challenge());
let client = reqwest::Client::new();
let err = provider
.send_voucher(&client, "https://example.com/pay", "0xnosuchid", 1000)
.await
.unwrap_err();
assert!(matches!(
err,
MppError::InvalidConfig(ref msg) if msg.contains("no channel found")
));
}
#[tokio::test]
async fn test_close_no_challenge_returns_none() {
let provider = make_test_provider();
let client = reqwest::Client::new();
let result = provider.close(&client, "https://example.com/pay").await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn test_close_no_open_channel_returns_none() {
let provider = make_test_provider();
*provider.last_challenge.lock().unwrap() = Some(make_test_challenge());
let client = reqwest::Client::new();
let result = provider.close(&client, "https://example.com/pay").await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_cumulative_reflects_channel_state() {
let provider = make_test_provider();
provider
.channels
.lock()
.unwrap()
.insert("key".to_string(), make_channel_entry(0xAB, 1000, true));
assert_eq!(provider.cumulative(), 1000);
provider
.channels
.lock()
.unwrap()
.get_mut("key")
.unwrap()
.cumulative_amount = 2000;
assert_eq!(provider.cumulative(), 2000);
}
#[test]
fn test_cumulative_does_not_decrease() {
let provider = make_test_provider();
provider
.channels
.lock()
.unwrap()
.insert("key".to_string(), make_channel_entry(0x01, 5000, true));
assert_eq!(provider.cumulative(), 5000);
provider
.channels
.lock()
.unwrap()
.get_mut("key")
.unwrap()
.cumulative_amount = 3000;
assert_eq!(provider.cumulative(), 3000);
}
#[test]
fn test_cumulative_returns_first_opened_channel_only() {
let provider = make_test_provider();
{
let mut channels = provider.channels.lock().unwrap();
channels.insert("key-a".to_string(), make_channel_entry(0x01, 5000, true));
channels.insert("key-b".to_string(), make_channel_entry(0x02, 9000, true));
}
let cum = provider.cumulative();
assert!(
cum == 5000 || cum == 9000,
"expected cumulative to be one channel's value, got: {cum}"
);
}
#[test]
fn test_channel_registry_multiple_channels() {
let provider = make_test_provider();
{
let mut channels = provider.channels.lock().unwrap();
channels.insert("key-a".to_string(), make_channel_entry(0x01, 5000, true));
channels.insert("key-b".to_string(), make_channel_entry(0x02, 3000, false));
}
assert_eq!(provider.channels().len(), 2);
assert_eq!(
provider.cumulative(),
5000,
"cumulative should only count the opened channel"
);
}
}