spacetimedb_client_api/routes/
energy.rs

1use axum::extract::{Path, Query, State};
2use axum::response::IntoResponse;
3use http::StatusCode;
4use serde::{Deserialize, Serialize};
5
6use spacetimedb::energy::EnergyQuanta;
7use spacetimedb_lib::Identity;
8
9use crate::auth::SpacetimeAuthRequired;
10use crate::{log_and_500, ControlStateDelegate, NodeDelegate};
11
12use super::identity::IdentityForUrl;
13
14#[derive(Deserialize)]
15pub struct IdentityParams {
16    identity: IdentityForUrl,
17}
18
19// TODO: do we want to require auth on this?
20pub async fn get_energy_balance<S: ControlStateDelegate>(
21    State(ctx): State<S>,
22    Path(IdentityParams { identity }): Path<IdentityParams>,
23) -> axum::response::Result<impl IntoResponse> {
24    let identity = Identity::from(identity);
25    get_budget_inner(ctx, &identity)
26}
27
28#[serde_with::serde_as]
29#[derive(Serialize)]
30struct BalanceResponse {
31    // Note: balance must be returned as a string to avoid truncation.
32    #[serde_as(as = "serde_with::DisplayFromStr")]
33    balance: i128,
34}
35
36#[derive(Deserialize)]
37pub struct AddEnergyQueryParams {
38    amount: Option<String>,
39}
40pub async fn add_energy<S: ControlStateDelegate>(
41    State(ctx): State<S>,
42    Query(AddEnergyQueryParams { amount }): Query<AddEnergyQueryParams>,
43    SpacetimeAuthRequired(auth): SpacetimeAuthRequired,
44) -> axum::response::Result<impl IntoResponse> {
45    // Nb.: Negative amount withdraws
46    let amount = amount.map(|s| s.parse::<u128>()).transpose().map_err(|e| {
47        log::error!("Failed to parse amount: {e:?}");
48        StatusCode::BAD_REQUEST
49    })?;
50
51    if let Some(satoshi) = amount {
52        ctx.add_energy(&auth.identity, EnergyQuanta::new(satoshi))
53            .await
54            .map_err(log_and_500)?;
55    }
56
57    // TODO: is this guaranteed to pull the updated balance?
58    let balance = ctx
59        .get_energy_balance(&auth.identity)
60        .map_err(log_and_500)?
61        .map_or(0, |quanta| quanta.get());
62
63    Ok(axum::Json(BalanceResponse { balance }))
64}
65
66fn get_budget_inner(ctx: impl ControlStateDelegate, identity: &Identity) -> axum::response::Result<impl IntoResponse> {
67    let balance = ctx
68        .get_energy_balance(identity)
69        .map_err(log_and_500)?
70        .map_or(0, |quanta| quanta.get());
71
72    Ok(axum::Json(BalanceResponse { balance }))
73}
74
75#[derive(Deserialize)]
76pub struct SetEnergyBalanceQueryParams {
77    balance: Option<String>,
78}
79pub async fn set_energy_balance<S: ControlStateDelegate>(
80    State(ctx): State<S>,
81    Path(IdentityParams { identity }): Path<IdentityParams>,
82    Query(SetEnergyBalanceQueryParams { balance }): Query<SetEnergyBalanceQueryParams>,
83    SpacetimeAuthRequired(auth): SpacetimeAuthRequired,
84) -> axum::response::Result<impl IntoResponse> {
85    // TODO(cloutiertyler): For the Testnet no one shall be authorized to set the energy balance
86    // of an identity. Each identity will begin with a default balance and they cannot be refilled.
87    // This will be a natural rate limiter until we can begin to sell energy.
88
89    // No one is able to be the dummy identity so this always returns unauthorized.
90    if auth.identity != Identity::__dummy() {
91        return Err(StatusCode::UNAUTHORIZED.into());
92    }
93
94    let identity = Identity::from(identity);
95
96    let desired_balance = balance
97        .map(|balance| balance.parse::<i128>())
98        .transpose()
99        .map_err(|err| {
100            log::error!("Failed to parse balance: {err:?}");
101            StatusCode::BAD_REQUEST
102        })?
103        .unwrap_or(0);
104    let current_balance = ctx
105        .get_energy_balance(&identity)
106        .map_err(log_and_500)?
107        .map_or(0, |quanta| quanta.get());
108
109    // TODO: this is a race condition waiting to happen. have a set_balance method on ControlStateDelegate
110    let delta = EnergyQuanta::new(desired_balance.abs_diff(current_balance));
111    if desired_balance > current_balance {
112        ctx.add_energy(&identity, delta).await.map_err(log_and_500)?;
113    } else {
114        ctx.withdraw_energy(&identity, delta).await.map_err(log_and_500)?;
115    }
116
117    Ok(axum::Json(BalanceResponse {
118        balance: desired_balance,
119    }))
120}
121
122pub fn router<S>() -> axum::Router<S>
123where
124    S: NodeDelegate + ControlStateDelegate + Clone + 'static,
125{
126    use axum::routing::get;
127    axum::Router::new().route(
128        "/:identity",
129        get(get_energy_balance::<S>)
130            .put(set_energy_balance::<S>)
131            .post(add_energy::<S>),
132    )
133}