Skip to content

Commit 5127b42

Browse files
authored
add blossom client (#536)
1 parent b951eda commit 5127b42

File tree

6 files changed

+151
-12
lines changed

6 files changed

+151
-12
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 0.3.15
2+
3+
* Upload and download files to and from Nostr using Blossom
4+
15
# 0.3.14
26

37
* Minting

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace.package]
2-
version = "0.3.14"
2+
version = "0.3.15"
33
edition = "2024"
44
license = "MIT"
55

crates/bcr-ebill-api/src/external/bitcoin.rs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ pub trait BitcoinClientApi: ServiceTraitBounds {
6464
}
6565

6666
#[derive(Clone)]
67-
pub struct BitcoinClient;
67+
pub struct BitcoinClient {
68+
cl: reqwest::Client,
69+
}
6870

6971
impl ServiceTraitBounds for BitcoinClient {}
7072

@@ -73,7 +75,9 @@ impl ServiceTraitBounds for MockBitcoinClientApi {}
7375

7476
impl BitcoinClient {
7577
pub fn new() -> Self {
76-
Self {}
78+
Self {
79+
cl: reqwest::Client::new(),
80+
}
7781
}
7882

7983
pub fn request_url(&self, path: &str) -> String {
@@ -117,7 +121,10 @@ impl Default for BitcoinClient {
117121
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
118122
impl BitcoinClientApi for BitcoinClient {
119123
async fn get_address_info(&self, address: &str) -> Result<AddressInfo> {
120-
let address: AddressInfo = reqwest::get(&self.request_url(&format!("/address/{address}")))
124+
let address: AddressInfo = self
125+
.cl
126+
.get(self.request_url(&format!("/address/{address}")))
127+
.send()
121128
.await
122129
.map_err(Error::from)?
123130
.json()
@@ -128,19 +135,24 @@ impl BitcoinClientApi for BitcoinClient {
128135
}
129136

130137
async fn get_transactions(&self, address: &str) -> Result<Transactions> {
131-
let transactions: Transactions =
132-
reqwest::get(&self.request_url(&format!("/address/{address}/txs")))
133-
.await
134-
.map_err(Error::from)?
135-
.json()
136-
.await
137-
.map_err(Error::from)?;
138+
let transactions: Transactions = self
139+
.cl
140+
.get(self.request_url(&format!("/address/{address}/txs")))
141+
.send()
142+
.await
143+
.map_err(Error::from)?
144+
.json()
145+
.await
146+
.map_err(Error::from)?;
138147

139148
Ok(transactions)
140149
}
141150

142151
async fn get_last_block_height(&self) -> Result<u64> {
143-
let height: u64 = reqwest::get(&self.request_url("/blocks/tip/height"))
152+
let height: u64 = self
153+
.cl
154+
.get(self.request_url("/blocks/tip/height"))
155+
.send()
144156
.await?
145157
.json()
146158
.await?;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use std::io::Write;
2+
3+
use async_trait::async_trait;
4+
use bcr_ebill_core::ServiceTraitBounds;
5+
use nostr::hashes::{
6+
Hash,
7+
sha256::{self, Hash as Sha256Hash},
8+
};
9+
use serde::Deserialize;
10+
use thiserror::Error;
11+
12+
/// Generic result type
13+
pub type Result<T> = std::result::Result<T, super::Error>;
14+
15+
/// Generic error type
16+
#[derive(Debug, Error)]
17+
pub enum Error {
18+
/// all errors originating from interacting with the web api
19+
#[error("External File Storage Web API error: {0}")]
20+
Api(#[from] reqwest::Error),
21+
/// all errors originating from invalid urls
22+
#[error("External File Storage Invalid Relay Url Error")]
23+
InvalidRelayUrl,
24+
/// all errors originating from invalid hashes
25+
#[error("External File Storage Invalid Hash")]
26+
InvalidHash,
27+
/// all errors originating from hashing
28+
#[error("External File Storage Hash Error")]
29+
Hash,
30+
}
31+
32+
#[cfg(test)]
33+
use mockall::automock;
34+
35+
#[cfg_attr(test, automock)]
36+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
37+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
38+
pub trait FileStorageClientApi: ServiceTraitBounds {
39+
/// Upload the given bytes, checking and returning the nostr_hash
40+
async fn upload(&self, relay_url: &str, bytes: Vec<u8>) -> Result<Sha256Hash>;
41+
/// Download the bytes with the given nostr_hash and compare if the hash matches the file
42+
async fn download(&self, relay_url: &str, nostr_hash: &str) -> Result<Vec<u8>>;
43+
}
44+
45+
#[derive(Debug, Clone, Default)]
46+
pub struct FileStorageClient {
47+
cl: reqwest::Client,
48+
}
49+
50+
impl ServiceTraitBounds for FileStorageClient {}
51+
52+
#[cfg(test)]
53+
impl ServiceTraitBounds for MockFileStorageClientApi {}
54+
55+
impl FileStorageClient {
56+
pub fn new() -> Self {
57+
Self {
58+
cl: reqwest::Client::new(),
59+
}
60+
}
61+
}
62+
63+
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
64+
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
65+
impl FileStorageClientApi for FileStorageClient {
66+
async fn upload(&self, relay_url: &str, bytes: Vec<u8>) -> Result<Sha256Hash> {
67+
// Calculate hash to compare with the hash we get back
68+
let mut hash_engine = sha256::HashEngine::default();
69+
if hash_engine.write_all(&bytes).is_err() {
70+
return Err(Error::Hash.into());
71+
}
72+
let hash = sha256::Hash::from_engine(hash_engine);
73+
74+
// PUT {relay_url}/upload
75+
let url = reqwest::Url::parse(relay_url)
76+
.and_then(|url| url.join("upload"))
77+
.map_err(|_| Error::InvalidRelayUrl)?;
78+
// Make upload request
79+
let resp: BlobDescriptorReply = self.cl.put(url).body(bytes).send().await?.json().await?;
80+
let nostr_hash = resp.sha256;
81+
82+
// Check hash
83+
if hash != nostr_hash {
84+
return Err(Error::InvalidHash.into());
85+
}
86+
87+
Ok(nostr_hash)
88+
}
89+
90+
async fn download(&self, relay_url: &str, nostr_hash: &str) -> Result<Vec<u8>> {
91+
// GET {relay_url}/{hash}
92+
let url = reqwest::Url::parse(relay_url)
93+
.and_then(|url| url.join(nostr_hash))
94+
.map_err(|_| Error::InvalidRelayUrl)?;
95+
// Make download request
96+
let resp: Vec<u8> = self.cl.get(url).send().await?.bytes().await?.into();
97+
98+
// Calculate hash to compare with the hash we sent
99+
let mut hash_engine = sha256::HashEngine::default();
100+
if hash_engine.write_all(&resp).is_err() {
101+
return Err(Error::Hash.into());
102+
}
103+
104+
// Check hash
105+
let hash = sha256::Hash::from_engine(hash_engine);
106+
if hash.to_string() != nostr_hash {
107+
return Err(Error::InvalidHash.into());
108+
}
109+
110+
Ok(resp)
111+
}
112+
}
113+
114+
#[derive(Debug, Clone, Deserialize)]
115+
pub struct BlobDescriptorReply {
116+
sha256: Sha256Hash,
117+
}

crates/bcr-ebill-api/src/external/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod bitcoin;
2+
pub mod file_storage;
23
pub mod mint;
34
pub mod time;
45

@@ -18,4 +19,8 @@ pub enum Error {
1819
/// all errors originating from the external mint API
1920
#[error("External Mint API error: {0}")]
2021
ExternalMintApi(#[from] mint::Error),
22+
23+
/// all errors originating from the external file storage API
24+
#[error("External File Storage API error: {0}")]
25+
ExternalFileStorageApi(#[from] file_storage::Error),
2126
}

crates/bcr-ebill-wasm/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ let config = {
4848
bitcoin_network: "testnet",
4949
esplora_base_url: "https://esplora.minibill.tech",
5050
nostr_relays: ["wss://bitcr-cloud-run-05-550030097098.europe-west1.run.app"],
51+
// nostr_relays: ["ws://localhost:8080"],
5152
// if set to true we will drop DMs from nostr that we don't have in contacts
5253
nostr_only_known_contacts: false,
5354
job_runner_initial_delay_seconds: 1,

0 commit comments

Comments
 (0)