diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 00000000..1ab60225 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,85 @@ +Forest Explorer Components Diagram + +```mermaid +flowchart TD + %% Forest Explorer Components + subgraph Main + direction TB + App[App] + Ctrl[Faucet Controller] + Model[Faucet Model] + Server[SSR Logic] + RateLimiter[Rate Limiter] + Constants[Network Constants] + end + + %% UI + subgraph UI + direction TB + Views[Views] + Home[Home] + Faucets[Faucets] + Components[UI Components] + end + + %% Sub-sections of UI + subgraph Faucets + direction TB + Mainnet[Mainnet] + Calibnet[Calibnet] + end + subgraph Components + direction TB + Layout[Layout] + Balance[Balance] + Transaction[Transaction] + Icon[Icon] + Alert[Alert] + Nav[Navigation] + end + + %% Utilities + subgraph Utils + direction TB + Addr[Address] + Key + Fmt[Format] + Err[Errors] + Msg[Message] + RpcCtx[RPC Context] + LotusJson[Lotus JSON] + end + + %% Main relations + App --> Ctrl + App --> Server + Ctrl --> Model + Ctrl --> RateLimiter + Ctrl --> Constants + Ctrl --> Utils + Ctrl --> Views + Server --> Utils + + %% UI relations + Views --> Faucets + Views --> Components + + %% UI sub-relations + Faucets --> Mainnet + Faucets --> Calibnet + Components --> Layout + Components --> Balance + Components --> Transaction + Components --> Icon + Components --> Alert + Components --> Nav + + %% Utilities relations + Utils --> LotusJson + Utils --> Addr + Utils --> Key + Utils --> Fmt + Utils --> Err + Utils --> Msg + Utils --> RpcCtx +``` diff --git a/e2e/script.js b/e2e/script.js index 40714502..944f7636 100644 --- a/e2e/script.js +++ b/e2e/script.js @@ -103,20 +103,21 @@ async function checkFooter(page, path) { const PAGES = [ { path: "", - buttons: ["To faucet list"], + buttons: ["Faucet List"], links: ["Filecoin Slack", "documentation"], }, { path: "/faucet", + buttons: ["Home"], links: ["Calibration Network Faucet", "Mainnet Network Faucet"], }, { path: "/faucet/calibnet", - buttons: ["Back to faucet list", "Transaction History", "Send"], + buttons: ["Faucet List", "Transaction History", "Send"], }, { path: "/faucet/mainnet", - buttons: ["Back to faucet list", "Transaction History", "Send"], + buttons: ["Faucet List", "Transaction History", "Send"], }, ]; diff --git a/src/app.rs b/src/app.rs index af9363be..2854c789 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,187 +1,12 @@ -use crate::icons::{CheckIcon, LightningIcon}; -use crate::rpc_context::{Provider, RpcContext}; -use fvm_shared::address::Network; +use crate::faucet::views::faucets::{calibnet::Faucet_Calibnet, mainnet::Faucet_Mainnet, Faucets}; +use crate::faucet::views::{components::layout::Footer, home::Explorer}; +use crate::utils::rpc_context::RpcContext; use leptos::prelude::*; -use leptos::{component, leptos_dom::helpers::event_target_value, view, IntoView}; +use leptos::{component, view, IntoView}; use leptos_meta::*; use leptos_router::components::*; use leptos_router::path; -#[allow(dead_code)] -pub fn shell(options: LeptosOptions) -> impl IntoView { - view! { - - - - Filecoin Forest Explorer Faucet - Get Free tFIL and FIL - - - - - - - - - - - } -} - -#[component] -pub fn Loader(loading: impl Fn() -> bool + 'static + Send) -> impl IntoView { - view! { } -} - -#[component] -pub fn BlockchainExplorer() -> impl IntoView { - let rpc_context = RpcContext::use_context(); - let network_name = LocalResource::new(move || { - let provider = rpc_context.get(); - async move { provider.network_name().await.ok() } - }); - - let network_version = LocalResource::new(move || { - let provider = rpc_context.get(); - async move { provider.network_version().await.ok() } - }); - - view! { -
-
-

- Filecoin Forest Explorer Faucet -

-

- The Filecoin Forest Explorer Faucet provides developers and users with free calibnet(tFil) and mainnet(FIL) to support their exploration, testing and development on the Filecoin network. -

-
- -
-
-

What does the faucet offer?

-
    -
  • - - Free calibnet tFIL for experimentation and development. -
  • -
  • - - - Real mainnet FIL for contributors engaging with the Filecoin ecosystem. - -
  • -
  • - - - A Quick and Easy way to request free tFIL and FIL - Just enter your wallet address. - -
  • -
-
- -
-

Why use this faucet?

-
    -
  • - - - Supports both calibnet and mainnet, unlike typical faucets. - -
  • -
  • - - - Enables testing of smart contracts, storage deals, and blockchain interactions without financial risk. - -
  • -
  • - - - Easy access to FIL for developers and users building on Filecoin. - -
  • -
  • - - - Need help? Visit the - - {" "} - Filecoin Slack - {" "}or - {" "} - documentation - . - -
  • -
-
-
- -
-
- -
- - - -
-
-
-

Network:

- Loading network name...

}> -

- {move || network_name.get().flatten()} - -

-
-
-
-

Version:

- Loading network version...

}> -

- {move || network_version.get().flatten()} - -

-
-
- -
- -
- } -} - -#[component] -fn Footer() -> impl IntoView { - view! { - - } -} - #[component] pub fn App() -> impl IntoView { provide_meta_context(); @@ -191,12 +16,12 @@ pub fn App() -> impl IntoView { -
+
- - - - + + + +
diff --git a/src/constants.rs b/src/faucet/constants.rs similarity index 77% rename from src/constants.rs rename to src/faucet/constants.rs index 3ee7aaa9..8774b175 100644 --- a/src/constants.rs +++ b/src/faucet/constants.rs @@ -1,16 +1,16 @@ -use std::sync::LazyLock; - use fvm_shared::econ::TokenAmount; +use std::sync::LazyLock; -/// The rate limit imposed by the CloudFlare's rate limiter, and also reflected in the user -/// interface. +// Calibnet constants pub const CALIBNET_RATE_LIMIT_SECONDS: i64 = 60; +pub static FIL_CALIBNET_UNIT: &str = "tFIL"; +/// The amount of mainnet FIL to be dripped to the user. This corresponds to 1 tFIL. +pub static CALIBNET_DRIP_AMOUNT: LazyLock = + LazyLock::new(|| TokenAmount::from_whole(1)); + +// Mainnet constants pub const MAINNET_RATE_LIMIT_SECONDS: i64 = 600; +pub static FIL_MAINNET_UNIT: &str = "FIL"; /// The amount of mainnet FIL to be dripped to the user. This corresponds to 0.01 FIL. pub static MAINNET_DRIP_AMOUNT: LazyLock = LazyLock::new(|| TokenAmount::from_nano(10_000_000)); -/// The amount of calibnet tFIL to be dripped to the user. -pub static CALIBNET_DRIP_AMOUNT: LazyLock = - LazyLock::new(|| TokenAmount::from_whole(1)); -pub static FIL_MAINNET_UNIT: &str = "FIL"; -pub static FIL_CALIBNET_UNIT: &str = "tFIL"; diff --git a/src/faucet/controller.rs b/src/faucet/controller.rs index fdd83317..bad9afc0 100644 --- a/src/faucet/controller.rs +++ b/src/faucet/controller.rs @@ -1,23 +1,19 @@ -use super::{model::FaucetModel, utils::sign_with_secret_key}; +use super::constants::{CALIBNET_DRIP_AMOUNT, CALIBNET_RATE_LIMIT_SECONDS, FIL_CALIBNET_UNIT}; +use super::constants::{FIL_MAINNET_UNIT, MAINNET_DRIP_AMOUNT, MAINNET_RATE_LIMIT_SECONDS}; + +use super::server::{faucet_address, sign_with_secret_key}; +use crate::faucet::model::FaucetModel; +use crate::utils::lotus_json::LotusJson; +use crate::utils::rpc_context::Provider; +use crate::utils::{address::parse_address, error::catch_all, message::message_transfer}; use cid::Cid; use fvm_shared::{address::Network, econ::TokenAmount}; use leptos::prelude::*; use leptos::task::spawn_local; use uuid::Uuid; -use crate::{ - address::parse_address, - constants::{CALIBNET_RATE_LIMIT_SECONDS, MAINNET_RATE_LIMIT_SECONDS}, - lotus_json::LotusJson, - message::message_transfer, - rpc_context::Provider, - utils::catch_all, -}; - -use super::utils::faucet_address; - #[derive(Clone)] -pub(super) struct FaucetController { +pub struct FaucetController { faucet: FaucetModel, } @@ -126,8 +122,8 @@ impl FaucetController { pub fn get_fil_unit(&self) -> String { match self.faucet.network { - Network::Mainnet => crate::constants::FIL_MAINNET_UNIT, - _ => crate::constants::FIL_CALIBNET_UNIT, + Network::Mainnet => FIL_MAINNET_UNIT, + _ => FIL_CALIBNET_UNIT, } .to_string() } @@ -175,9 +171,9 @@ impl FaucetController { fn get_drip_amount(&self) -> TokenAmount { if self.faucet.network == Network::Mainnet { - crate::constants::MAINNET_DRIP_AMOUNT.clone() + MAINNET_DRIP_AMOUNT.clone() } else { - crate::constants::CALIBNET_DRIP_AMOUNT.clone() + CALIBNET_DRIP_AMOUNT.clone() } } @@ -204,9 +200,9 @@ impl FaucetController { from, addr, if is_mainnet { - crate::constants::MAINNET_DRIP_AMOUNT.clone() + MAINNET_DRIP_AMOUNT.clone() } else { - crate::constants::CALIBNET_DRIP_AMOUNT.clone() + CALIBNET_DRIP_AMOUNT.clone() }, ); msg.sequence = nonce; diff --git a/src/faucet/mod.rs b/src/faucet/mod.rs index d5de990f..d7765f27 100644 --- a/src/faucet/mod.rs +++ b/src/faucet/mod.rs @@ -1,4 +1,8 @@ mod controller; mod model; -pub mod utils; +#[cfg(feature = "ssr")] +mod rate_limiter; + +pub mod constants; +pub mod server; pub mod views; diff --git a/src/faucet/model.rs b/src/faucet/model.rs index 0e58058b..b7f9d923 100644 --- a/src/faucet/model.rs +++ b/src/faucet/model.rs @@ -1,6 +1,6 @@ use cid::Cid; use fvm_shared::{address::Network, econ::TokenAmount}; -use leptos::prelude::{LocalResource, RwSignal, Trigger}; +use leptos::prelude::*; use uuid::Uuid; #[derive(Clone)] diff --git a/src/rate_limiter.rs b/src/faucet/rate_limiter.rs similarity index 95% rename from src/rate_limiter.rs rename to src/faucet/rate_limiter.rs index 6ecc7b50..9050bc0c 100644 --- a/src/rate_limiter.rs +++ b/src/faucet/rate_limiter.rs @@ -1,4 +1,4 @@ -use crate::constants::{CALIBNET_RATE_LIMIT_SECONDS, MAINNET_RATE_LIMIT_SECONDS}; +use crate::faucet::constants::{CALIBNET_RATE_LIMIT_SECONDS, MAINNET_RATE_LIMIT_SECONDS}; use chrono::{DateTime, Duration, Utc}; use worker::*; diff --git a/src/faucet/utils.rs b/src/faucet/server.rs similarity index 59% rename from src/faucet/utils.rs rename to src/faucet/server.rs index ac110304..efaa0516 100644 --- a/src/faucet/utils.rs +++ b/src/faucet/server.rs @@ -1,12 +1,11 @@ #[cfg(feature = "ssr")] -use crate::key::{sign, Key}; -use crate::{lotus_json::LotusJson, message::SignedMessage}; -use anyhow::{anyhow, Result}; +use crate::utils::key::{sign, Key}; +use crate::utils::lotus_json::{signed_message::SignedMessage, LotusJson}; +use anyhow::Result; #[cfg(feature = "ssr")] use fvm_shared::address::Network; -use fvm_shared::{address::Address, econ::TokenAmount, message::Message}; +use fvm_shared::{address::Address, message::Message}; use leptos::{prelude::ServerFnError, server}; -use url::Url; #[server] pub async fn faucet_address(is_mainnet: bool) -> Result, ServerFnError> { @@ -24,14 +23,14 @@ pub async fn sign_with_secret_key( msg: LotusJson, is_mainnet: bool, ) -> Result, ServerFnError> { - use crate::message::message_cid; + use crate::utils::lotus_json::signed_message::message_cid; use leptos::server_fn::error; use send_wrapper::SendWrapper; let LotusJson(msg) = msg; let cid = message_cid(&msg); let amount_limit = match is_mainnet { - true => crate::constants::MAINNET_DRIP_AMOUNT.clone(), - false => crate::constants::CALIBNET_DRIP_AMOUNT.clone(), + true => crate::faucet::constants::MAINNET_DRIP_AMOUNT.clone(), + false => crate::faucet::constants::CALIBNET_DRIP_AMOUNT.clone(), }; if msg.value > amount_limit { return Err(ServerFnError::ServerError( @@ -51,9 +50,9 @@ pub async fn sign_with_secret_key( let network = if is_mainnet { "mainnet" } else { "calibnet" }; let may_sign = rate_limiter_disabled || query_rate_limiter(network).await?; let rate_limit_seconds = if is_mainnet { - crate::constants::MAINNET_RATE_LIMIT_SECONDS + crate::faucet::constants::MAINNET_RATE_LIMIT_SECONDS } else { - crate::constants::CALIBNET_RATE_LIMIT_SECONDS + crate::faucet::constants::CALIBNET_RATE_LIMIT_SECONDS }; if !may_sign { return Err(ServerFnError::ServerError(format!( @@ -85,7 +84,7 @@ pub async fn sign_with_secret_key( #[cfg(feature = "ssr")] pub async fn secret_key(network: Network) -> Result { - use crate::key::KeyInfo; + use crate::utils::key::KeyInfo; use axum::Extension; use leptos::server_fn::error; use leptos_axum::extract; @@ -125,76 +124,3 @@ pub async fn query_rate_limiter(network: &str) -> Result { .json::() .await?) } - -/// Formats FIL balance to a human-readable string with two decimal places and a unit. -pub fn format_balance(balance: &TokenAmount, unit: &str) -> String { - format!( - "{:.2} {unit}", - balance.to_string().parse::().unwrap_or_default(), - ) -} - -/// Types of search paths in Filecoin explorer. -#[derive(Copy, Clone)] -pub enum SearchPath { - Transaction, - Address, -} - -impl SearchPath { - pub fn as_str(&self) -> &'static str { - match self { - SearchPath::Transaction => "txs/", - SearchPath::Address => "address/", - } - } -} - -/// Constructs a URL combining base URL, search path, and an identifier. -pub fn format_url(base_url: &Url, path: SearchPath, identifier: &str) -> Result { - base_url - .join(path.as_str())? - .join(identifier) - .map_err(|e| anyhow!("Failed to join URL: {}", e)) -} - -#[cfg(test)] -mod tests { - use super::*; - use fvm_shared::econ::TokenAmount; - - #[test] - fn test_format_balance() { - let cases = [ - (TokenAmount::from_whole(1), "1.00 FIL"), - (TokenAmount::from_whole(0), "0.00 FIL"), - (TokenAmount::from_nano(10e6 as i64), "0.01 FIL"), - (TokenAmount::from_nano(999_999_999), "1.00 FIL"), - ]; - for (balance, expected) in cases.iter() { - assert_eq!(format_balance(balance, "FIL"), *expected); - } - } - - #[test] - fn test_format_url() { - let base = Url::parse("https://test.com/").unwrap(); - let cases = [ - ( - SearchPath::Transaction, - "0xdef456", - "https://test.com/txs/0xdef456", - ), - ( - SearchPath::Address, - "0xabc123", - "https://test.com/address/0xabc123", - ), - ]; - - for (path, query, expected) in cases.iter() { - let result = format_url(&base, *path, query).unwrap(); - assert_eq!(result.as_str(), *expected); - } - } -} diff --git a/src/faucet/views.rs b/src/faucet/views.rs deleted file mode 100644 index 31bbf063..00000000 --- a/src/faucet/views.rs +++ /dev/null @@ -1,370 +0,0 @@ -use std::collections::HashSet; -use std::time::Duration; - -use fvm_shared::address::Network; -use leptos::prelude::*; -use leptos::task::spawn_local; -use leptos::{component, leptos_dom::helpers::event_target_value, view, IntoView}; -use leptos_meta::{Meta, Title}; -#[cfg(feature = "hydrate")] -use leptos_use::*; -use url::Url; - -use crate::faucet::controller::FaucetController; -use crate::faucet::utils::SearchPath; -use crate::faucet::utils::{format_balance, format_url}; -use crate::rpc_context::{Provider, RpcContext}; - -const MESSAGE_FADE_AFTER: Duration = Duration::new(3, 0); -const MESSAGE_REMOVAL_AFTER: Duration = Duration::new(3, 500_000_000); - -#[component] -fn FaucetBalance(faucet: RwSignal) -> impl IntoView { - view! { -
-

Faucet Balance:

- Loading faucet balance...

} - }> - {move || { - if faucet.get().is_low_balance() { - let topup_req_url = option_env!("FAUCET_TOPUP_REQ_URL"); - view! { - - "Request Faucet Top-up" - - } - .into_any() - } else { - view! { -

- {format_balance(&faucet.get().get_faucet_balance(), &faucet.get().get_fil_unit())} -

- } - .into_any() - } - }} -
-
- } -} - -#[component] -fn TargetBalance(faucet: RwSignal) -> impl IntoView { - view! { -
-

Target Balance:

- Loading target balance...

}> -

- {move || format_balance(&faucet.get().get_target_balance(), &faucet.get().get_fil_unit())} -

-
-
- } -} - -#[component] -pub fn Faucet(target_network: Network) -> impl IntoView { - let faucet = RwSignal::new(FaucetController::new(target_network)); - - #[cfg(feature = "hydrate")] - let _ = use_interval_fn( - move || { - let duration = faucet.get().get_send_rate_limit_remaining(); - if duration > 0 { - faucet.get().set_send_rate_limit_remaining(duration - 1); - } - }, - 1000, - ); - - #[cfg(feature = "hydrate")] - let _ = use_interval_fn( - move || { - faucet.get().refetch_balances(); - }, - 5000, - ); - - let (fading_messages, set_fading_messages) = signal(HashSet::new()); - let faucet_tx_base_url = match target_network { - Network::Mainnet => { - RwSignal::new(option_env!("FAUCET_TX_URL_MAINNET").and_then(|url| Url::parse(url).ok())) - } - Network::Testnet => RwSignal::new( - option_env!("FAUCET_TX_URL_CALIBNET").and_then(|url| Url::parse(url).ok()), - ), - }; - view! { - {move || { - let errors = faucet.get().get_error_messages(); - if !errors.is_empty() { - view! { -
- {errors - .into_iter() - .map(|(id, error)| { - spawn_local(async move { - set_timeout( - move || { - set_fading_messages - .update(|fading| { - fading.insert(id); - }); - }, - MESSAGE_FADE_AFTER, - ); - set_timeout( - move || { - set_fading_messages - .update(|fading| { - fading.remove(&id); - }); - faucet.get().remove_error_message(id); - }, - MESSAGE_REMOVAL_AFTER, - ); - }); - // Start fading message after 3 seconds - - // Remove message after 3.5 seconds - - view! { - - } - }) - .collect::>()} -
- } - .into_any() - } else { - ().into_any() - } - }} -
-
- - {move || { - if faucet.get().is_send_disabled() { - view! { - - } - .into_any() - } else if faucet.get().get_send_rate_limit_remaining() > 0 { - let duration = faucet.get().get_send_rate_limit_remaining(); - view! { - - } - .into_any() - } else if faucet.get().is_low_balance() { - view! { - - } - .into_any() - } else { - view! { - - } - .into_any() - } - }} -
-
- - -
-
- {move || { - let messages = faucet.get().get_sent_messages(); - if !messages.is_empty() { - view! { -
-

Transactions:

-
    - {messages - .into_iter() - .map(|(msg, sent)| { - let (cid, status) = if sent { - let cid = faucet_tx_base_url - .get() - .as_ref() - .and_then(|base_url| { - format_url(base_url, SearchPath::Transaction, &msg.to_string()).ok() - }) - .map(|tx_url| { - view! { - - {msg.to_string()} - - } - .into_any() - }) - .unwrap_or_else(|| view! { {msg.to_string()} }.into_any()); - (cid, "(confirmed)") - } else { - let cid = view! { {msg.to_string()} }.into_any(); - (cid, "(pending)") - }; - view! {
  • "CID:" {cid} {status}
  • } - }) - .collect::>()} -
-
- } - .into_any() - } else { - ().into_any() - } - }} -
-
- {move || { - match faucet_tx_base_url.get() { - Some(ref base_url) => { - match format_url(base_url, SearchPath::Address, &faucet.get().get_sender_address()) { - Ok(addr_url) => { - view! { - - } - .into_any() - } - Err(_) => ().into_any(), - } - } - None => ().into_any(), - } - }} -
- } -} - -#[component] -pub fn Faucets() -> impl IntoView { - view! { - - <Meta name="description" content="Filecoin Faucet list" /> - <div class="text-center"> - <h1 class="text-4xl font-bold mb-6 text-center">Filecoin Faucet List</h1> - <a class="text-blue-600" href="/faucet/calibnet"> - Calibration Network Faucet - </a> - <br /> - <a class="text-blue-600" href="/faucet/mainnet"> - Mainnet Network Faucet - </a> - </div> - } -} - -#[component] -pub fn Faucet_Calibnet() -> impl IntoView { - let rpc_context = RpcContext::use_context(); - // Set rpc context to calibnet url - rpc_context.set(Provider::get_network_url(Network::Testnet)); - - view! { - <Title text="Filecoin Faucet - Calibration Network" /> - <Meta - name="description" - content="Filecoin Calibration Network Faucet dispensing tokens for testing purposes." - /> - <div> - <h1 class="text-4xl font-bold mb-6 text-center">Filecoin Calibnet Faucet</h1> - <Faucet target_network=Network::Testnet /> - </div> - <div class="text-center mt-4"> - "This faucet distributes " - {format_balance(&crate::constants::CALIBNET_DRIP_AMOUNT, crate::constants::FIL_CALIBNET_UNIT)} - " per request. It is rate-limited to 1 request per " {crate::constants::CALIBNET_RATE_LIMIT_SECONDS} - " seconds. Farming is discouraged and will result in more stringent rate limiting in the future and/or permanent bans." - </div> - } -} - -#[component] -pub fn Faucet_Mainnet() -> impl IntoView { - let rpc_context = RpcContext::use_context(); - // Set rpc context to mainnet url - rpc_context.set(Provider::get_network_url(Network::Mainnet)); - - view! { - <Title text="Filecoin Faucet - Mainnet" /> - <Meta name="description" content="Filecoin Mainnet Faucet dispensing tokens for testing purposes." /> - <div> - <h1 class="text-4xl font-bold mb-6 text-center">Filecoin Mainnet Faucet</h1> - <Faucet target_network=Network::Mainnet /> - <div class="text-center mt-4"> - "This faucet distributes " - {format_balance(&crate::constants::MAINNET_DRIP_AMOUNT, crate::constants::FIL_MAINNET_UNIT)} - " per request. It is rate-limited to 1 request per " {crate::constants::MAINNET_RATE_LIMIT_SECONDS} - " seconds. Farming is discouraged and will result in more stringent rate limiting in the future and/or permanent bans or service termination. Faucet funds are limited and may run out. They are replenished periodically." - </div> - </div> - } -} diff --git a/src/faucet/views/components/alert.rs b/src/faucet/views/components/alert.rs new file mode 100644 index 00000000..f5756f2b --- /dev/null +++ b/src/faucet/views/components/alert.rs @@ -0,0 +1,73 @@ +use crate::faucet::controller::FaucetController; +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos::{component, view, IntoView}; +use std::collections::HashSet; +use std::time::Duration; +use uuid::Uuid; + +const MESSAGE_FADE_AFTER: Duration = Duration::new(3, 0); +const MESSAGE_REMOVAL_AFTER: Duration = Duration::new(3, 500_000_000); + +#[component] +pub fn ErrorMessages( + errors: Vec<(Uuid, String)>, + faucet: RwSignal<FaucetController>, +) -> impl IntoView { + let (fading_messages, set_fading_messages) = signal(HashSet::new()); + view! { + <div class="error-alert-container"> + {errors + .into_iter() + .map(|(id, error)| { + spawn_local(async move { + set_timeout( + move || { + set_fading_messages + .update(|fading| { + fading.insert(id); + }); + }, + MESSAGE_FADE_AFTER, + ); + set_timeout( + move || { + set_fading_messages + .update(|fading| { + fading.remove(&id); + }); + faucet.get().remove_error_message(id); + }, + MESSAGE_REMOVAL_AFTER, + ); + }); + + view! { + <div + class=move || { + if fading_messages.get().contains(&id) { "error-alert--faded" } else { "error-alert" } + } + role="alert" + > + <span class="block sm:inline">{error}</span> + <span class="absolute top-0 bottom-0 right-0 px-4 py-3"> + <svg + class="error-icon" + role="button" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + on:click=move |_| { + faucet.get().remove_error_message(id); + } + > + <title>Close + + + +
+ } + }) + .collect::>()} + + } +} diff --git a/src/faucet/views/components/balance.rs b/src/faucet/views/components/balance.rs new file mode 100644 index 00000000..fa0a8708 --- /dev/null +++ b/src/faucet/views/components/balance.rs @@ -0,0 +1,50 @@ +use leptos::prelude::*; +use leptos::{component, view, IntoView}; + +use crate::faucet::controller::FaucetController; +use crate::utils::format::format_balance; + +#[component] +pub fn FaucetBalance(faucet: RwSignal) -> impl IntoView { + view! { +
+

Faucet Balance:

+ Loading faucet balance...

} + }> + {move || { + if faucet.get().is_low_balance() { + let topup_req_url = option_env!("FAUCET_TOPUP_REQ_URL"); + view! { + + "Request Faucet Top-up" + + } + .into_any() + } else { + view! { +

+ {format_balance(&faucet.get().get_faucet_balance(), &faucet.get().get_fil_unit())} +

+ } + .into_any() + } + }} +
+
+ } +} + +#[component] +pub fn TargetBalance(faucet: RwSignal) -> impl IntoView { + view! { +
+

Target Balance:

+ Loading target balance...

}> +

+ {move || format_balance(&faucet.get().get_target_balance(), &faucet.get().get_fil_unit())} +

+
+
+ } +} diff --git a/src/faucet/views/components/icons.rs b/src/faucet/views/components/icons.rs new file mode 100644 index 00000000..d8c21d59 --- /dev/null +++ b/src/faucet/views/components/icons.rs @@ -0,0 +1,24 @@ +use leptos::prelude::*; + +#[component] +pub fn CheckIcon(#[prop(default = String::new())] class: String) -> impl IntoView { + view! { + + + + } +} + +#[component] +pub fn LightningIcon(#[prop(default = String::new())] class: String) -> impl IntoView { + view! { + + + + } +} + +#[component] +pub fn Loader(loading: impl Fn() -> bool + 'static + Send) -> impl IntoView { + view! { } +} diff --git a/src/faucet/views/components/layout.rs b/src/faucet/views/components/layout.rs new file mode 100644 index 00000000..2c81f65e --- /dev/null +++ b/src/faucet/views/components/layout.rs @@ -0,0 +1,34 @@ +use leptos::prelude::*; +use leptos::{component, view, IntoView}; + +#[component] +pub fn Header() -> impl IntoView { + view! { +
+

Filecoin Forest Explorer Faucet

+

+ The Filecoin Forest Explorer Faucet provides developers and users with free calibnet(tFil) and mainnet(FIL) to support their exploration, testing and development on the Filecoin network. +

+
+ } +} + +#[component] +pub fn Footer() -> impl IntoView { + view! { + + } +} diff --git a/src/faucet/views/components/mod.rs b/src/faucet/views/components/mod.rs new file mode 100644 index 00000000..0c54beb1 --- /dev/null +++ b/src/faucet/views/components/mod.rs @@ -0,0 +1,6 @@ +pub mod alert; +pub mod balance; +pub mod icons; +pub mod layout; +pub mod nav; +pub mod transaction; diff --git a/src/faucet/views/components/nav.rs b/src/faucet/views/components/nav.rs new file mode 100644 index 00000000..3a025486 --- /dev/null +++ b/src/faucet/views/components/nav.rs @@ -0,0 +1,24 @@ +use leptos::prelude::*; +use leptos::{component, view, IntoView}; + +#[component] +pub fn GotoFaucetList() -> impl IntoView { + view! { +
+ +
+ } +} + +#[component] +pub fn GotoHome() -> impl IntoView { + view! { +
+ +
+ } +} diff --git a/src/faucet/views/components/transaction.rs b/src/faucet/views/components/transaction.rs new file mode 100644 index 00000000..37205194 --- /dev/null +++ b/src/faucet/views/components/transaction.rs @@ -0,0 +1,77 @@ +use crate::utils::format::{format_url, SearchPath}; +use ::cid::Cid; +use leptos::prelude::*; +use leptos::{component, view, IntoView}; +use url::Url; + +use crate::faucet::controller::FaucetController; + +#[component] +pub fn TransactionList( + messages: Vec<(Cid, bool)>, + faucet_tx_base_url: RwSignal>, +) -> impl IntoView { + view! { +
+

Transactions:

+
    + {messages + .into_iter() + .map(|(msg, sent)| { + let (cid, status) = if sent { + let cid = faucet_tx_base_url + .get() + .as_ref() + .and_then(|base_url| { + format_url(base_url, SearchPath::Transaction, &msg.to_string()).ok() + }) + .map(|tx_url| { + view! { + + {msg.to_string()} + + } + .into_any() + }) + .unwrap_or_else(|| view! { {msg.to_string()} }.into_any()); + (cid, "(confirmed)") + } else { + let cid = view! { {msg.to_string()} }.into_any(); + (cid, "(pending)") + }; + view! {
  • "CID:" {cid} {status}
  • } + }) + .collect::>()} +
+
+ } +} + +#[component] +pub fn TransactionHistoryButton( + faucet: RwSignal, + faucet_tx_base_url: RwSignal>, +) -> impl IntoView { + view! { + {move || { + match faucet_tx_base_url.get() { + Some(ref base_url) => { + match format_url(base_url, SearchPath::Address, &faucet.get().get_sender_address()) { + Ok(addr_url) => { + view! { + + } + .into_any() + } + Err(_) => ().into_any(), + } + } + None => ().into_any(), + } + }} + } +} diff --git a/src/faucet/views/faucets/calibnet.rs b/src/faucet/views/faucets/calibnet.rs new file mode 100644 index 00000000..126d4158 --- /dev/null +++ b/src/faucet/views/faucets/calibnet.rs @@ -0,0 +1,34 @@ +use super::Faucet; +use crate::faucet::constants::{ + CALIBNET_DRIP_AMOUNT, CALIBNET_RATE_LIMIT_SECONDS, FIL_CALIBNET_UNIT, +}; +use crate::utils::format::format_balance; +use crate::utils::rpc_context::{Provider, RpcContext}; +use fvm_shared::address::Network; +use leptos::prelude::*; +use leptos::{component, view, IntoView}; +use leptos_meta::{Meta, Title}; + +#[component] +pub fn Faucet_Calibnet() -> impl IntoView { + let rpc_context = RpcContext::use_context(); + // Set rpc context to calibnet url + rpc_context.set(Provider::get_network_url(Network::Testnet)); + + view! { + + <Meta + name="description" + content="Filecoin Calibration Network Faucet dispensing tokens for testing purposes." + /> + <div> + <h1 class="header">Filecoin Calibnet Faucet</h1> + <Faucet target_network=Network::Testnet /> + </div> + <div class="description"> + "This faucet distributes " {format_balance(&CALIBNET_DRIP_AMOUNT, FIL_CALIBNET_UNIT)} + " per request. It is rate-limited to 1 request per " {CALIBNET_RATE_LIMIT_SECONDS} + " seconds. Farming is discouraged and will result in more stringent rate limiting in the future and/or permanent bans." + </div> + } +} diff --git a/src/faucet/views/faucets/mainnet.rs b/src/faucet/views/faucets/mainnet.rs new file mode 100644 index 00000000..ec9597f8 --- /dev/null +++ b/src/faucet/views/faucets/mainnet.rs @@ -0,0 +1,29 @@ +use super::Faucet; +use crate::faucet::constants::{FIL_MAINNET_UNIT, MAINNET_DRIP_AMOUNT, MAINNET_RATE_LIMIT_SECONDS}; +use crate::utils::format::format_balance; +use crate::utils::rpc_context::{Provider, RpcContext}; +use fvm_shared::address::Network; +use leptos::prelude::*; +use leptos::{component, view, IntoView}; +use leptos_meta::{Meta, Title}; + +#[component] +pub fn Faucet_Mainnet() -> impl IntoView { + let rpc_context = RpcContext::use_context(); + // Set rpc context to mainnet url + rpc_context.set(Provider::get_network_url(Network::Mainnet)); + + view! { + <Title text="Filecoin Faucet - Mainnet" /> + <Meta name="description" content="Filecoin Mainnet Faucet dispensing tokens for testing purposes." /> + <div> + <h1 class="header">Filecoin Mainnet Faucet</h1> + <Faucet target_network=Network::Mainnet /> + <div class="description"> + "This faucet distributes " {format_balance(&MAINNET_DRIP_AMOUNT, FIL_MAINNET_UNIT)} + " per request. It is rate-limited to 1 request per " {MAINNET_RATE_LIMIT_SECONDS} + " seconds. Farming is discouraged and will result in more stringent rate limiting in the future and/or permanent bans or service termination. Faucet funds are limited and may run out. They are replenished periodically." + </div> + </div> + } +} diff --git a/src/faucet/views/faucets/mod.rs b/src/faucet/views/faucets/mod.rs new file mode 100644 index 00000000..20e7d484 --- /dev/null +++ b/src/faucet/views/faucets/mod.rs @@ -0,0 +1,164 @@ +pub mod calibnet; +pub mod mainnet; + +use fvm_shared::address::Network; +use leptos::prelude::*; +use leptos::{component, leptos_dom::helpers::event_target_value, view, IntoView}; +use leptos_meta::{Meta, Title}; +#[cfg(feature = "hydrate")] +use leptos_use::use_interval_fn; +use url::Url; + +use crate::faucet::controller::FaucetController; +use crate::faucet::views::components::alert::ErrorMessages; +use crate::faucet::views::components::balance::{FaucetBalance, TargetBalance}; +use crate::faucet::views::components::nav::{GotoFaucetList, GotoHome}; +use crate::faucet::views::components::transaction::{TransactionHistoryButton, TransactionList}; + +#[component] +fn FaucetInput(faucet: RwSignal<FaucetController>) -> impl IntoView { + view! { + <div class="input-container"> + <input + type="text" + placeholder="Enter target address (Filecoin or Ethereum style)" + prop:value=faucet.get().get_target_address() + on:input=move |ev| { faucet.get().set_target_address(event_target_value(&ev)) } + on:keydown=move |ev| { + if ev.key() == "Enter" && !faucet.get().is_send_disabled() + && faucet.get().get_send_rate_limit_remaining() <= 0 + { + faucet.get().drip(); + } + } + class="input" + /> + {move || { + if faucet.get().is_send_disabled() { + view! { + <button class="btn-disabled" disabled=true> + "Sending..." + </button> + } + .into_any() + } else if faucet.get().get_send_rate_limit_remaining() > 0 { + let duration = faucet.get().get_send_rate_limit_remaining(); + view! { + <button class="btn-disabled" disabled=true> + {format!("Rate-limited! {duration}s")} + </button> + } + .into_any() + } else if faucet.get().is_low_balance() { + view! { + <button class="btn-disabled" disabled=true> + "Send" + </button> + } + .into_any() + } else { + view! { + <button + class="btn-enabled" + on:click=move |_| { + faucet.get().drip(); + } + > + Send + </button> + } + .into_any() + } + }} + </div> + } +} + +#[cfg(feature = "hydrate")] +fn use_faucet_polling(faucet: RwSignal<FaucetController>) { + let _ = use_interval_fn( + move || { + let duration = faucet.get().get_send_rate_limit_remaining(); + if duration > 0 { + faucet.get().set_send_rate_limit_remaining(duration - 1); + } + }, + 1000, + ); + + let _ = use_interval_fn( + move || { + faucet.get().refetch_balances(); + }, + 5000, + ); +} + +#[component] +pub fn Faucet(target_network: Network) -> impl IntoView { + let faucet = RwSignal::new(FaucetController::new(target_network)); + + #[cfg(feature = "hydrate")] + { + use_faucet_polling(faucet); + } + + let faucet_tx_base_url = match target_network { + Network::Mainnet => { + RwSignal::new(option_env!("FAUCET_TX_URL_MAINNET").and_then(|url| Url::parse(url).ok())) + } + Network::Testnet => RwSignal::new( + option_env!("FAUCET_TX_URL_CALIBNET").and_then(|url| Url::parse(url).ok()), + ), + }; + + view! { + {move || { + let errors = faucet.get().get_error_messages(); + if !errors.is_empty() { + view! { <ErrorMessages errors=errors faucet=faucet /> }.into_any() + } else { + ().into_any() + } + }} + <div class="faucet-container"> + <FaucetInput faucet=faucet /> + <div class="balance-container"> + <FaucetBalance faucet=faucet /> + <TargetBalance faucet=faucet /> + </div> + <hr class="separator" /> + {move || { + let messages = faucet.get().get_sent_messages(); + if !messages.is_empty() { + view! { <TransactionList messages=messages faucet_tx_base_url=faucet_tx_base_url /> }.into_any() + } else { + ().into_any() + } + }} + </div> + <div class="nav-container"> + <TransactionHistoryButton faucet=faucet faucet_tx_base_url=faucet_tx_base_url /> + <GotoFaucetList /> + </div> + } +} + +#[component] +pub fn Faucets() -> impl IntoView { + view! { + <Title text="Filecoin Faucets" /> + <Meta name="description" content="Filecoin Faucet list" /> + <div class="faucet-list-container"> + <h1 class="header">Filecoin Faucet List</h1> + <a class="link-text-hover" href="/faucet/calibnet"> + Calibration Network Faucet + </a> + <br /> + <a class="link-text-hover" href="/faucet/mainnet"> + Mainnet Network Faucet + </a> + <GotoHome /> + </div> + } +} diff --git a/src/faucet/views/home.rs b/src/faucet/views/home.rs new file mode 100644 index 00000000..2f58a59a --- /dev/null +++ b/src/faucet/views/home.rs @@ -0,0 +1,134 @@ +use crate::faucet::views::components::icons::{CheckIcon, LightningIcon, Loader}; +use crate::faucet::views::components::layout::Header; +use crate::faucet::views::components::nav::GotoFaucetList; +use crate::utils::rpc_context::{Provider, RpcContext}; +use fvm_shared::address::Network; +use leptos::prelude::*; +use leptos::{component, leptos_dom::helpers::event_target_value, view, IntoView}; + +#[component] +fn FaucetOverview() -> impl IntoView { + view! { + <div class="overview-container"> + <div class="card"> + <h2 class="card-title">What does the faucet offer?</h2> + <ul class="list"> + <li class="list-element"> + <CheckIcon /> + <span class="list-text">Free calibnet tFIL for experimentation and development.</span> + </li> + <li class="list-element"> + <CheckIcon /> + <span class="list-text"> + Real mainnet FIL for contributors engaging with the Filecoin ecosystem. + </span> + </li> + <li class="list-element"> + <CheckIcon /> + <span class="list-text"> + A Quick and Easy way to request free tFIL and FIL - Just enter your wallet address. + </span> + </li> + </ul> + </div> + + <div class="card"> + <h2 class="card-title">Why use this faucet?</h2> + <ul class="list"> + <li class="list-element"> + <LightningIcon /> + <span class="list-text">Supports both calibnet and mainnet, unlike typical faucets.</span> + </li> + <li class="list-element"> + <LightningIcon /> + <span class="list-text"> + Enables testing of smart contracts, storage deals, and blockchain interactions without financial risk. + </span> + </li> + <li class="list-element"> + <LightningIcon /> + <span class="list-text">Easy access to FIL for developers and users building on Filecoin.</span> + </li> + <li class="list-element"> + <LightningIcon /> + <span class="list-text"> + Need help? Visit the <a class="link-text" href="https://filecoin.io/slack" target="_blank"> + {" "} + Filecoin Slack + </a>{" "}or <a class="link-text" href="https://docs.filecoin.io" target="_blank"> + {" "} + documentation + </a>. + </span> + </li> + </ul> + </div> + </div> + } +} + +#[component] +fn NetworkSelection( + rpc_context: RpcContext, + network_name: LocalResource<Option<String>>, + network_version: LocalResource<Option<u64>>, +) -> impl IntoView { + view! { + <div class="network-selector"> + <div class="dropdown"> + <select on:change=move |ev| { rpc_context.set(event_target_value(&ev)) } class="dropdown-items"> + <option value=Provider::get_network_url(Network::Testnet)>Glif.io Calibnet</option> + <option value=Provider::get_network_url(Network::Mainnet)>Glif.io Mainnet</option> + </select> + <div class="dropdown-icon"> + <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> + </svg> + </div> + </div> + + <div class="network-info"> + <p>Network:</p> + <Transition fallback=move || view! { <p>Loading network name...</p> }> + <p> + <span>{move || network_name.get().flatten()}</span> + <Loader loading=move || network_name.get().is_none() /> + </p> + </Transition> + </div> + + <div class="network-info"> + <p>Version:</p> + <Transition fallback=move || view! { <p>Loading network version...</p> }> + <p> + <span>{move || network_version.get().flatten()}</span> + <Loader loading=move || network_version.get().is_none() /> + </p> + </Transition> + </div> + </div> + } +} + +#[component] +pub fn Explorer() -> impl IntoView { + let rpc_context = RpcContext::use_context(); + let network_name = LocalResource::new(move || { + let provider = rpc_context.get(); + async move { provider.network_name().await.ok() } + }); + + let network_version = LocalResource::new(move || { + let provider = rpc_context.get(); + async move { provider.network_version().await.ok() } + }); + + view! { + <main class="main-container"> + <Header /> + <FaucetOverview /> + <NetworkSelection rpc_context=rpc_context network_name=network_name network_version=network_version /> + <GotoFaucetList /> + </main> + } +} diff --git a/src/faucet/views/mod.rs b/src/faucet/views/mod.rs new file mode 100644 index 00000000..283ddb2e --- /dev/null +++ b/src/faucet/views/mod.rs @@ -0,0 +1,3 @@ +pub mod components; +pub mod faucets; +pub mod home; diff --git a/src/icons/check_icon.rs b/src/icons/check_icon.rs deleted file mode 100644 index 50f228b3..00000000 --- a/src/icons/check_icon.rs +++ /dev/null @@ -1,15 +0,0 @@ -use leptos::prelude::*; - -#[component] -pub fn CheckIcon(#[prop(default = String::new())] class: String) -> impl IntoView { - view! { - <svg - class=format!("h-5 w-5 text-green-500 mr-2 flex-shrink-0 {}", class) - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> - </svg> - } -} diff --git a/src/icons/lightning_icon.rs b/src/icons/lightning_icon.rs deleted file mode 100644 index 4eacc6fc..00000000 --- a/src/icons/lightning_icon.rs +++ /dev/null @@ -1,15 +0,0 @@ -use leptos::prelude::*; - -#[component] -pub fn LightningIcon(#[prop(default = String::new())] class: String) -> impl IntoView { - view! { - <svg - class=format!("h-5 w-5 text-blue-500 mr-2 flex-shrink-0 {}", class) - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> - </svg> - } -} diff --git a/src/icons/mod.rs b/src/icons/mod.rs deleted file mode 100644 index 431eef02..00000000 --- a/src/icons/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod check_icon; -mod lightning_icon; - -pub use check_icon::CheckIcon; -pub use lightning_icon::LightningIcon; diff --git a/src/lib.rs b/src/lib.rs index 1253376f..520857dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,8 @@ mod app; -mod rpc_context; +mod utils; #[cfg(feature = "hydrate")] use app::App; -mod address; -mod constants; mod faucet; -mod icons; -mod key; -mod lotus_json; -mod message; -#[cfg(feature = "ssr")] -mod rate_limiter; -mod utils; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen] @@ -25,15 +16,35 @@ pub fn hydrate() { mod ssr_imports { use std::sync::Arc; - use crate::{ - app::{shell, App}, - faucet, - }; + use crate::{app::App, faucet}; use axum::{routing::post, Extension, Router}; use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; + use leptos_meta::*; use worker::{event, Context, Env, HttpRequest, Result}; + fn shell(options: LeptosOptions) -> impl IntoView { + view! { + <!DOCTYPE html> + <html lang="en"> + <head> + <title>Filecoin Forest Explorer Faucet - Get Free tFIL and FIL + + + + + + + + + + + } + } + fn router(env: Env) -> Router { let leptos_options = LeptosOptions::builder() .output_name("client") @@ -55,8 +66,8 @@ mod ssr_imports { #[event(start)] fn register() { - server_fn::axum::register_explicit::(); - server_fn::axum::register_explicit::(); + server_fn::axum::register_explicit::(); + server_fn::axum::register_explicit::(); } #[event(fetch)] diff --git a/src/lotus_json/signed_message.rs b/src/lotus_json/signed_message.rs deleted file mode 100644 index ca889e17..00000000 --- a/src/lotus_json/signed_message.rs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - -use crate::message::{Message, SignedMessage}; -use ::cid::Cid; -use fvm_shared::crypto::signature::Signature; - -use super::*; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub struct SignedMessageLotusJson { - #[serde(with = "crate::lotus_json")] - message: Message, - #[serde(with = "crate::lotus_json")] - signature: Signature, - #[serde( - with = "crate::lotus_json", - rename = "CID", - skip_serializing_if = "Option::is_none", - default - )] - cid: Option, -} - -impl HasLotusJson for SignedMessage { - type LotusJson = SignedMessageLotusJson; - - fn into_lotus_json(self) -> Self::LotusJson { - let cid = Some(self.cid()); - let Self { message, signature } = self; - Self::LotusJson { - message, - signature, - cid, - } - } - - fn from_lotus_json(lotus_json: Self::LotusJson) -> Self { - let Self::LotusJson { - message, - signature, - cid: _ignored, // See notes on Message - } = lotus_json; - Self { message, signature } - } -} diff --git a/src/message.rs b/src/message.rs deleted file mode 100644 index a36b954d..00000000 --- a/src/message.rs +++ /dev/null @@ -1,63 +0,0 @@ -use cid::Cid; -use fvm_ipld_encoding::Error; -use fvm_ipld_encoding::RawBytes; -pub use fvm_shared::message::Message; -use fvm_shared::{ - address::Address, - crypto::signature::{Signature, SignatureType}, - econ::TokenAmount, - METHOD_SEND, -}; -use multihash_codetable::{Code, MultihashDigest as _}; -use serde::{Deserialize, Serialize}; - -fn from_cbor_blake2b256(obj: &S) -> Result { - let bytes = fvm_ipld_encoding::to_vec(obj)?; - Ok(Cid::new_v1( - fvm_ipld_encoding::DAG_CBOR, - Code::Blake2b256.digest(&bytes), - )) -} - -pub fn message_transfer(from: Address, to: Address, value: TokenAmount) -> Message { - Message { - from, - to, - value, - method_num: METHOD_SEND, - params: RawBytes::new(vec![]), - gas_limit: 0, - gas_fee_cap: TokenAmount::from_atto(0), - gas_premium: TokenAmount::from_atto(0), - version: 0, - sequence: 0, - } -} - -pub fn message_cid(msg: &Message) -> cid::Cid { - from_cbor_blake2b256(msg).expect("message serialization is infallible") -} - -#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Hash, Eq)] -pub struct SignedMessage { - pub message: Message, - pub signature: Signature, -} - -impl SignedMessage { - /// Checks if the signed message is a BLS message. - pub fn is_bls(&self) -> bool { - self.signature.signature_type() == SignatureType::BLS - } - - // Important note: `msg.cid()` is different from - // `Cid::from_cbor_blake2b256(msg)`. The behavior comes from Lotus, and - // Lotus, by, definition, is correct. - pub fn cid(&self) -> cid::Cid { - if self.is_bls() { - message_cid(&self.message) - } else { - from_cbor_blake2b256(self).expect("message serialization is infallible") - } - } -} diff --git a/src/address.rs b/src/utils/address.rs similarity index 100% rename from src/address.rs rename to src/utils/address.rs diff --git a/src/utils.rs b/src/utils/error.rs similarity index 99% rename from src/utils.rs rename to src/utils/error.rs index 660555d8..a0b96f12 100644 --- a/src/utils.rs +++ b/src/utils/error.rs @@ -1,6 +1,5 @@ -use std::future::Future; - use leptos::prelude::{RwSignal, Update}; +use std::future::Future; use uuid::Uuid; pub async fn catch_all( diff --git a/src/utils/format.rs b/src/utils/format.rs new file mode 100644 index 00000000..3844633e --- /dev/null +++ b/src/utils/format.rs @@ -0,0 +1,76 @@ +use anyhow::{anyhow, Result}; +use fvm_shared::econ::TokenAmount; +use url::Url; + +/// Formats FIL balance to a human-readable string with two decimal places and a unit. +pub fn format_balance(balance: &TokenAmount, unit: &str) -> String { + format!( + "{:.2} {unit}", + balance.to_string().parse::().unwrap_or_default(), + ) +} + +/// Types of search paths in Filecoin explorer. +#[derive(Copy, Clone)] +pub enum SearchPath { + Transaction, + Address, +} + +impl SearchPath { + pub fn as_str(&self) -> &'static str { + match self { + SearchPath::Transaction => "txs/", + SearchPath::Address => "address/", + } + } +} + +/// Constructs a URL combining base URL, search path, and an identifier. +pub fn format_url(base_url: &Url, path: SearchPath, identifier: &str) -> Result { + base_url + .join(path.as_str())? + .join(identifier) + .map_err(|e| anyhow!("Failed to join URL: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use fvm_shared::econ::TokenAmount; + + #[test] + fn test_format_balance() { + let cases = [ + (TokenAmount::from_whole(1), "1.00 FIL"), + (TokenAmount::from_whole(0), "0.00 FIL"), + (TokenAmount::from_nano(10e6 as i64), "0.01 FIL"), + (TokenAmount::from_nano(999_999_999), "1.00 FIL"), + ]; + for (balance, expected) in cases.iter() { + assert_eq!(format_balance(balance, "FIL"), *expected); + } + } + + #[test] + fn test_format_url() { + let base = Url::parse("https://test.com/").unwrap(); + let cases = [ + ( + SearchPath::Transaction, + "0xdef456", + "https://test.com/txs/0xdef456", + ), + ( + SearchPath::Address, + "0xabc123", + "https://test.com/address/0xabc123", + ), + ]; + + for (path, query, expected) in cases.iter() { + let result = format_url(&base, *path, query).unwrap(); + assert_eq!(result.as_str(), *expected); + } + } +} diff --git a/src/key.rs b/src/utils/key.rs similarity index 100% rename from src/key.rs rename to src/utils/key.rs diff --git a/src/lotus_json/address.rs b/src/utils/lotus_json/address.rs similarity index 92% rename from src/lotus_json/address.rs rename to src/utils/lotus_json/address.rs index 2d328df5..2ac97c89 100644 --- a/src/lotus_json/address.rs +++ b/src/utils/lotus_json/address.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; use fvm_shared::address::{Address, Network}; diff --git a/src/lotus_json/big_int.rs b/src/utils/lotus_json/big_int.rs similarity index 67% rename from src/lotus_json/big_int.rs rename to src/utils/lotus_json/big_int.rs index a7236dae..2e1f1909 100644 --- a/src/lotus_json/big_int.rs +++ b/src/utils/lotus_json/big_int.rs @@ -1,12 +1,9 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; use fvm_shared::bigint::BigInt; #[derive(Clone, Serialize, Deserialize)] -pub struct BigIntLotusJson(#[serde(with = "crate::lotus_json::stringify")] BigInt); +pub struct BigIntLotusJson(#[serde(with = "crate::utils::lotus_json::stringify")] BigInt); impl HasLotusJson for BigInt { type LotusJson = BigIntLotusJson; diff --git a/src/lotus_json/cid.rs b/src/utils/lotus_json/cid.rs similarity index 73% rename from src/lotus_json/cid.rs rename to src/utils/lotus_json/cid.rs index 69c6bdad..aa2fa8da 100644 --- a/src/lotus_json/cid.rs +++ b/src/utils/lotus_json/cid.rs @@ -1,11 +1,8 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; #[derive(Clone, Serialize, Deserialize)] pub struct CidLotusJson { - #[serde(rename = "/", with = "crate::lotus_json::stringify")] + #[serde(rename = "/", with = "crate::utils::lotus_json::stringify")] slash: ::cid::Cid, } diff --git a/src/lotus_json/message.rs b/src/utils/lotus_json/message.rs similarity index 80% rename from src/lotus_json/message.rs rename to src/utils/lotus_json/message.rs index 4d968a30..dd874280 100644 --- a/src/lotus_json/message.rs +++ b/src/utils/lotus_json/message.rs @@ -1,35 +1,30 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; - -use crate::message::Message; use fvm_ipld_encoding::RawBytes; -use fvm_shared::{address::Address, econ::TokenAmount}; +use fvm_shared::{address::Address, econ::TokenAmount, message::Message}; #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct MessageLotusJson { #[serde(default)] version: u64, - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::utils::lotus_json")] to: Address, - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::utils::lotus_json")] from: Address, #[serde(default)] nonce: u64, - #[serde(with = "crate::lotus_json", default)] + #[serde(with = "crate::utils::lotus_json", default)] value: TokenAmount, #[serde(default)] gas_limit: u64, - #[serde(with = "crate::lotus_json", default)] + #[serde(with = "crate::utils::lotus_json", default)] gas_fee_cap: TokenAmount, - #[serde(with = "crate::lotus_json", default)] + #[serde(with = "crate::utils::lotus_json", default)] gas_premium: TokenAmount, #[serde(default)] method: u64, #[serde( - with = "crate::lotus_json", + with = "crate::utils::lotus_json", skip_serializing_if = "Option::is_none", default )] diff --git a/src/lotus_json/mod.rs b/src/utils/lotus_json/mod.rs similarity index 98% rename from src/lotus_json/mod.rs rename to src/utils/lotus_json/mod.rs index 3376535a..4b09ec57 100644 --- a/src/lotus_json/mod.rs +++ b/src/utils/lotus_json/mod.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - //! In the Filecoin ecosystem, there are TWO different ways to present a domain object: //! - CBOR (defined in [`fvm_ipld_encoding`]). //! This is the wire format. @@ -169,11 +166,12 @@ mod message; mod opt; mod signature; mod signature_type; -mod signed_message; mod token_amount; mod vec; mod vec_u8; +pub mod signed_message; + // mod nonempty; // can't make snapshots of generic type // mod opt; // can't make snapshots of generic type mod raw_bytes; // fvm_ipld_encoding::RawBytes: !quickcheck::Arbitrary @@ -185,7 +183,7 @@ mod raw_bytes; // fvm_ipld_encoding::RawBytes: !quickcheck::Arbitrary #[serde(rename_all = "PascalCase")] pub struct MessageLookup { pub height: i64, - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::utils::lotus_json")] pub message: Cid, } lotus_json_with_self!(MessageLookup); @@ -361,7 +359,7 @@ impl LotusJson { macro_rules! lotus_json_with_self { ($($domain_ty:ty),* $(,)?) => { $( - impl $crate::lotus_json::HasLotusJson for $domain_ty { + impl $crate::utils::lotus_json::HasLotusJson for $domain_ty { type LotusJson = Self; fn into_lotus_json(self) -> Self::LotusJson { self diff --git a/src/lotus_json/opt.rs b/src/utils/lotus_json/opt.rs similarity index 85% rename from src/lotus_json/opt.rs rename to src/utils/lotus_json/opt.rs index b066e1d4..40447fd6 100644 --- a/src/lotus_json/opt.rs +++ b/src/utils/lotus_json/opt.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; // TODO(forest): https://github.com/ChainSafe/forest/issues/4032 diff --git a/src/lotus_json/raw_bytes.rs b/src/utils/lotus_json/raw_bytes.rs similarity index 80% rename from src/lotus_json/raw_bytes.rs rename to src/utils/lotus_json/raw_bytes.rs index 15452512..a89ae924 100644 --- a/src/lotus_json/raw_bytes.rs +++ b/src/utils/lotus_json/raw_bytes.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::{vec_u8::VecU8LotusJson, *}; use fvm_ipld_encoding::RawBytes; diff --git a/src/lotus_json/signature.rs b/src/utils/lotus_json/signature.rs similarity index 80% rename from src/lotus_json/signature.rs rename to src/utils/lotus_json/signature.rs index 9f301ce6..3f3a390b 100644 --- a/src/lotus_json/signature.rs +++ b/src/utils/lotus_json/signature.rs @@ -1,15 +1,12 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; use fvm_shared::crypto::signature::{Signature, SignatureType}; #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SignatureLotusJson { - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::utils::lotus_json")] r#type: SignatureType, - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::utils::lotus_json")] data: Vec, } diff --git a/src/lotus_json/signature_type.rs b/src/utils/lotus_json/signature_type.rs similarity index 86% rename from src/lotus_json/signature_type.rs rename to src/utils/lotus_json/signature_type.rs index b528fac7..76aeabee 100644 --- a/src/lotus_json/signature_type.rs +++ b/src/utils/lotus_json/signature_type.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; use fvm_shared::crypto::signature::SignatureType; @@ -15,7 +12,7 @@ use fvm_shared::crypto::signature::SignatureType; #[serde(untagged)] // try an int, then a string pub enum SignatureTypeLotusJson { Integer(SignatureType), - // String(#[serde(with = "crate::lotus_json::stringify")] SignatureType), + // String(#[serde(with = "crate::utils::lotus_json::stringify")] SignatureType), } impl HasLotusJson for SignatureType { diff --git a/src/utils/lotus_json/signed_message.rs b/src/utils/lotus_json/signed_message.rs new file mode 100644 index 00000000..f32c7bf4 --- /dev/null +++ b/src/utils/lotus_json/signed_message.rs @@ -0,0 +1,82 @@ +use super::HasLotusJson; +use ::cid::Cid; +use fvm_ipld_encoding::Error; +use fvm_shared::crypto::signature::Signature; +use fvm_shared::crypto::signature::SignatureType; +use fvm_shared::message::Message; +use multihash_codetable::{Code, MultihashDigest as _}; +use serde::{Deserialize, Serialize}; + +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Hash, Eq)] +pub struct SignedMessage { + pub message: Message, + pub signature: Signature, +} + +fn from_cbor_blake2b256(obj: &S) -> Result { + let bytes = fvm_ipld_encoding::to_vec(obj)?; + Ok(Cid::new_v1( + fvm_ipld_encoding::DAG_CBOR, + Code::Blake2b256.digest(&bytes), + )) +} +pub fn message_cid(msg: &Message) -> Cid { + from_cbor_blake2b256(msg).expect("message serialization is infallible") +} + +impl SignedMessage { + /// Checks if the signed message is a BLS message. + pub fn is_bls(&self) -> bool { + self.signature.signature_type() == SignatureType::BLS + } + + // Important note: `msg.cid()` is different from + // `Cid::from_cbor_blake2b256(msg)`. The behavior comes from Lotus, and + // Lotus, by, definition, is correct. + pub fn cid(&self) -> Cid { + if self.is_bls() { + message_cid(&self.message) + } else { + from_cbor_blake2b256(self).expect("message serialization is infallible") + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SignedMessageLotusJson { + #[serde(with = "crate::utils::lotus_json")] + message: Message, + #[serde(with = "crate::utils::lotus_json")] + signature: Signature, + #[serde( + with = "crate::utils::lotus_json", + rename = "CID", + skip_serializing_if = "Option::is_none", + default + )] + cid: Option, +} + +impl HasLotusJson for SignedMessage { + type LotusJson = SignedMessageLotusJson; + + fn into_lotus_json(self) -> Self::LotusJson { + let cid = Some(self.cid()); + let Self { message, signature } = self; + Self::LotusJson { + message, + signature, + cid, + } + } + + fn from_lotus_json(lotus_json: Self::LotusJson) -> Self { + let Self::LotusJson { + message, + signature, + cid: _ignored, // See notes on Message + } = lotus_json; + Self { message, signature } + } +} diff --git a/src/lotus_json/token_amount.rs b/src/utils/lotus_json/token_amount.rs similarity index 82% rename from src/lotus_json/token_amount.rs rename to src/utils/lotus_json/token_amount.rs index d300a7ff..d7d0e19c 100644 --- a/src/lotus_json/token_amount.rs +++ b/src/utils/lotus_json/token_amount.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; use fvm_shared::bigint::BigInt; use fvm_shared::econ::TokenAmount; @@ -8,7 +5,7 @@ use fvm_shared::econ::TokenAmount; #[derive(Clone, Serialize, Deserialize)] #[serde(transparent)] // name the field for clarity pub struct TokenAmountLotusJson { - #[serde(with = "crate::lotus_json")] + #[serde(with = "crate::utils::lotus_json")] attos: BigInt, } diff --git a/src/lotus_json/vec.rs b/src/utils/lotus_json/vec.rs similarity index 94% rename from src/lotus_json/vec.rs rename to src/utils/lotus_json/vec.rs index 8a802b0a..56ba8da7 100644 --- a/src/lotus_json/vec.rs +++ b/src/utils/lotus_json/vec.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; impl HasLotusJson for Vec diff --git a/src/lotus_json/vec_u8.rs b/src/utils/lotus_json/vec_u8.rs similarity index 89% rename from src/lotus_json/vec_u8.rs rename to src/utils/lotus_json/vec_u8.rs index 84aa3ccd..19b9b3a3 100644 --- a/src/lotus_json/vec_u8.rs +++ b/src/utils/lotus_json/vec_u8.rs @@ -1,6 +1,3 @@ -// Copyright 2019-2024 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - use super::*; // This code looks odd so we can diff --git a/src/utils/message.rs b/src/utils/message.rs new file mode 100644 index 00000000..74452cce --- /dev/null +++ b/src/utils/message.rs @@ -0,0 +1,18 @@ +use fvm_ipld_encoding::RawBytes; +use fvm_shared::message::Message; +use fvm_shared::{address::Address, econ::TokenAmount, METHOD_SEND}; + +pub fn message_transfer(from: Address, to: Address, value: TokenAmount) -> Message { + Message { + from, + to, + value, + method_num: METHOD_SEND, + params: RawBytes::new(vec![]), + gas_limit: 0, + gas_fee_cap: TokenAmount::from_atto(0), + gas_premium: TokenAmount::from_atto(0), + version: 0, + sequence: 0, + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 00000000..ef28000c --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub mod address; +pub mod error; +pub mod format; +pub mod key; +pub mod lotus_json; +pub mod message; +pub mod rpc_context; diff --git a/src/rpc_context.rs b/src/utils/rpc_context.rs similarity index 96% rename from src/rpc_context.rs rename to src/utils/rpc_context.rs index cf15bd2f..7ba72b9b 100644 --- a/src/rpc_context.rs +++ b/src/utils/rpc_context.rs @@ -7,8 +7,7 @@ use reqwest::Client; use serde_json::{json, Value}; use std::sync::LazyLock; -use crate::lotus_json::{HasLotusJson, LotusJson}; -use crate::message::SignedMessage; +use super::lotus_json::{signed_message::SignedMessage, HasLotusJson, LotusJson}; static CLIENT: LazyLock = LazyLock::new(Client::new); @@ -173,7 +172,7 @@ impl Provider { pub async fn state_search_msg( &self, msg: Cid, - ) -> anyhow::Result> { + ) -> anyhow::Result> { invoke_rpc_method( &self.url, "Filecoin.StateSearchMsg", diff --git a/style/tailwind.css b/style/tailwind.css index 53873041..4afeca5b 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -2,76 +2,175 @@ @tailwind components; @tailwind utilities; -/* HTML: */ -.loader { - display: inline-block; - width: 1em; - aspect-ratio: 1; - border-radius: 50%; - border: 3px solid #514b82; - animation: - l20-1 0.8s infinite linear alternate, - l20-2 1.6s infinite linear; - margin-left: 0.5em; - margin-right: 0.5em; -} - -@keyframes l20-1 { - 0% { - clip-path: polygon(50% 50%, 0 0, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%) +@layer components { + .app-container { + @apply flex flex-col min-h-screen space-y-8 py-10 px-6 min-h-screen; } - - 12.5% { - clip-path: polygon(50% 50%, 0 0, 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0%) + .main-container { + @apply min-h-screen flex flex-col flex-grow space-y-8; } - - 25% { - clip-path: polygon(50% 50%, 0 0, 50% 0%, 100% 0%, 100% 100%, 100% 100%, 100% 100%) + .overview-container { + @apply grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl w-full m-auto; } - - 50% { - clip-path: polygon(50% 50%, 0 0, 50% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100%) + .faucet-list-container { + @apply text-center space-y-8; } - - 62.5% { - clip-path: polygon(50% 50%, 100% 0, 100% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100%) + .faucet-container { + @apply max-w-2xl mx-auto; } - - 75% { - clip-path: polygon(50% 50%, 100% 100%, 100% 100%, 100% 100%, 100% 100%, 50% 100%, 0% 100%) + .input-container { + @apply my-4 flex; } - - 100% { - clip-path: polygon(50% 50%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 0% 100%) + .balance-container { + @apply flex justify-between my-4; } -} - -@keyframes l20-2 { - 0% { - transform: scaleY(1) rotate(0deg) + .transaction-container { + @apply mt-4 space-y-2; } - - 49.99% { - transform: scaleY(1) rotate(135deg) + .nav-container { + @apply flex justify-center space-x-4; } - - 50% { - transform: scaleY(-1) rotate(0deg) + .error-alert-container { + @apply fixed top-4 left-1/2 transform -translate-x-1/2 z-50; } - - 100% { - transform: scaleY(-1) rotate(-135deg) + .network-info { + @apply flex items-center w-[300px] justify-between; + } + .network-selector { + @apply space-y-6 flex flex-col items-center; + } + .separator { + @apply my-4 border-t border-gray-300; + } + .card { + @apply bg-white p-6 rounded-lg border border-gray-100; + } + .card-title { + @apply text-lg font-semibold text-gray-900 mb-4; + } + .input { + @apply flex-grow border border-gray-300 p-2 rounded-l; + } + .title { + @apply text-lg font-semibold; + } + .list { + @apply space-y-3; + } + .bullet-list { + @apply list-disc pl-5; + } + .list-element { + @apply flex items-start; + } + .list-text { + @apply text-gray-600; + } + .link-text { + @apply text-blue-500; + } + .link-text-hover { + @apply text-blue-500 hover:text-blue-600; + } + .description { + @apply text-center mt-4; + } + .balance { + @apply text-xl; + } + .header { + @apply text-4xl font-bold mb-6 text-center; + } + .header-large { + @apply text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl; + } + .footer { + @apply py-4 text-center; + } + .btn { + @apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-6 rounded-lg; + } + .btn-enabled { + @apply bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-r; + } + .btn-disabled { + @apply bg-gray-400 text-white font-bold py-2 px-4 rounded-r; + } + .btn-topup { + @apply bg-orange-500 hover:bg-orange-600 text-white font-bold text-sm py-1 px-2 rounded; + } + .dropdown { + @apply relative w-64; + } + .dropdown-icon { + @apply pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700; + } + .dropdown-items { + @apply w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 hover:border-gray-400 transition-colors cursor-pointer appearance-none; + } + .check-icon { + @apply h-5 w-5 text-green-500 mr-2 flex-shrink-0; + } + .lighting-icon { + @apply h-5 w-5 text-blue-500 mr-2 flex-shrink-0; + } + .error-icon { + @apply fill-current h-6 w-6 text-red-500; + } + .error-alert { + @apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-2 w-96; + } + .error-alert--faded { + @apply opacity-0 transition-opacity bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-2 w-96; + } + @keyframes l20-1 { + 0% { + clip-path: polygon(50% 50%, 0 0, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%); + } + 12.5% { + clip-path: polygon(50% 50%, 0 0, 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0%); + } + 25% { + clip-path: polygon(50% 50%, 0 0, 50% 0%, 100% 0%, 100% 100%, 100% 100%, 100% 100%); + } + 50% { + clip-path: polygon(50% 50%, 0 0, 50% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100%); + } + 62.5% { + clip-path: polygon(50% 50%, 100% 0, 100% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100%); + } + 75% { + clip-path: polygon(50% 50%, 100% 100%, 100% 100%, 100% 100%, 100% 100%, 50% 100%, 0% 100%); + } + 100% { + clip-path: polygon(50% 50%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 0% 100%); + } } -} -.opacity-0 { - opacity: 0; + @keyframes l20-2 { + 0% { + transform: scaleY(1) rotate(0deg); + } + 49.99% { + transform: scaleY(1) rotate(135deg); + } + 50% { + transform: scaleY(-1) rotate(0deg); + } + 100% { + transform: scaleY(-1) rotate(-135deg); + } + } } -.opacity-100 { - opacity: 1; +@layer utilities { + .opacity-0 { + opacity: 0; + } + .opacity-100 { + opacity: 1; + } + .transition-opacity { + transition: opacity 0.5s ease-in-out; + } } - -.transition-opacity { - transition: opacity 0.5s ease-in-out; -} \ No newline at end of file