Skip to content

Commit 7999cfe

Browse files
committed
Implement a minimal BTC/USD price feed feature
1 parent 82493f4 commit 7999cfe

File tree

7 files changed

+108
-0
lines changed

7 files changed

+108
-0
lines changed

crates/cli-client/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ nostr = { version = "0.44.2" }
4848
nostr-sdk = { version = "0.44.1" }
4949

5050
minreq = { version = "2.14", features = ["https", "json-using-serde"] }
51+
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
52+
rust_decimal = { version = "1", features = ["serde"] }
53+
async-trait = "0.1.89"

crates/cli-client/src/cli/commands.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ pub enum Command {
3333
/// Show my holdings with expiration warnings
3434
Positions,
3535

36+
/// Show current BTC/USD price feed
37+
PriceFeed,
38+
3639
/// Sync coin-store with blockchain via Esplora and/or NOSTR
3740
Sync {
3841
#[command(subcommand)]

crates/cli-client/src/cli/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod interactive;
44
mod option;
55
mod option_offer;
66
mod positions;
7+
mod price;
78
mod sync;
89
mod tables;
910
mod tx;
@@ -102,6 +103,7 @@ impl Cli {
102103
Command::Browse => self.run_browse(config).await,
103104
Command::Positions => self.run_positions(config).await,
104105
Command::Sync { command } => self.run_sync(config, command).await,
106+
Command::PriceFeed => price::run_price_feed().await,
105107
Command::Config => {
106108
println!("{config:#?}");
107109
Ok(())

crates/cli-client/src/cli/price.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use crate::error::Error;
2+
use crate::price_fetcher::{CoingeckoPriceFetcher, PriceFetcher};
3+
use rust_decimal::Decimal;
4+
5+
pub async fn fetch_btc_usd_price<T: PriceFetcher>(fetcher: &T) -> Result<Decimal, Error> {
6+
Ok(fetcher.fetch_price().await?)
7+
}
8+
9+
pub async fn run_price_feed() -> Result<(), Error> {
10+
let fetcher = CoingeckoPriceFetcher::new()?;
11+
let price = fetch_btc_usd_price(&fetcher).await?;
12+
13+
println!("1 BTC = ${}", price.round_dp(2));
14+
15+
Ok(())
16+
}

crates/cli-client/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,7 @@ pub enum Error {
5555

5656
#[error("Taproot pubkey generation error: {0}")]
5757
TaprootPubkeyGen(#[from] contracts::error::TaprootPubkeyGenError),
58+
59+
#[error("Price feed error: {0}")]
60+
PriceFeed(#[from] crate::price_fetcher::PriceFetcherError),
5861
}

crates/cli-client/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod explorer;
77
mod fee;
88
mod logging;
99
mod metadata;
10+
mod price_fetcher;
1011
mod signing;
1112
mod sync;
1213
mod wallet;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use async_trait::async_trait;
2+
use rust_decimal::Decimal;
3+
use serde::Deserialize;
4+
use std::time::Duration;
5+
use thiserror::Error;
6+
7+
#[derive(Error, Debug)]
8+
pub enum PriceFetcherError {
9+
#[error("Request error: {0}")]
10+
Request(#[from] reqwest::Error),
11+
#[error("Rate limit exceeded (429)")]
12+
RateLimit,
13+
#[error("Response status error: {0}")]
14+
Status(u16),
15+
#[error("Parse error: {0}")]
16+
Parse(String),
17+
#[error("Client build error: {0}")]
18+
Build(String),
19+
}
20+
21+
#[derive(Deserialize)]
22+
struct BitcoinResponse {
23+
bitcoin: BitcoinPrice,
24+
}
25+
26+
#[derive(Deserialize)]
27+
struct BitcoinPrice {
28+
usd: Decimal,
29+
}
30+
31+
#[async_trait]
32+
pub trait PriceFetcher {
33+
async fn fetch_price(&self) -> Result<Decimal, PriceFetcherError>;
34+
}
35+
36+
pub struct CoingeckoPriceFetcher {
37+
client: reqwest::Client,
38+
base_url: String,
39+
}
40+
41+
impl CoingeckoPriceFetcher {
42+
const URL: &'static str = "https://api.coingecko.com/api/v3";
43+
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
44+
45+
pub fn new() -> Result<Self, PriceFetcherError> {
46+
let client = reqwest::Client::builder()
47+
.user_agent("simplicity-dex/1.0")
48+
.timeout(Self::REQUEST_TIMEOUT)
49+
.build()
50+
.map_err(|e| PriceFetcherError::Build(e.to_string()))?;
51+
52+
Ok(Self {
53+
client,
54+
base_url: Self::URL.to_string(),
55+
})
56+
}
57+
}
58+
59+
#[async_trait]
60+
impl PriceFetcher for CoingeckoPriceFetcher {
61+
async fn fetch_price(&self) -> Result<Decimal, PriceFetcherError> {
62+
let url = format!("{}/simple/price", self.base_url);
63+
64+
let resp = self
65+
.client
66+
.get(&url)
67+
.query(&[("ids", "bitcoin"), ("vs_currencies", "usd"), ("precision", "8")])
68+
.send()
69+
.await?;
70+
71+
match resp.status() {
72+
reqwest::StatusCode::OK => {
73+
let data: BitcoinResponse = resp.json().await.map_err(|e| PriceFetcherError::Parse(e.to_string()))?;
74+
Ok(data.bitcoin.usd)
75+
}
76+
reqwest::StatusCode::TOO_MANY_REQUESTS => Err(PriceFetcherError::RateLimit),
77+
status => Err(PriceFetcherError::Status(status.as_u16())),
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)