Skip to content

Commit 435eab6

Browse files
authored
Merge branch 'main' into codex/hip-3-perps
2 parents 6d912c3 + 5510fe5 commit 435eab6

245 files changed

Lines changed: 3911 additions & 1922 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Guidance for AI assistants (Claude Code, Gemini, Codex, etc.) collaborating on t
1414
- [Tests](skills/tests.md) — Test conventions, mocks, integration tests
1515
- [Defensive Programming](skills/defensive-programming.md) — Safety rules and exhaustive patterns
1616
- [Common Issues](skills/common-issues.md) — Known anti-patterns and their fixes
17+
- [Swapper Checklist](skills/swapper-checklist.md) — Integration checklist for swapper providers
1718

1819
## Task Completion
1920

Cargo.lock

Lines changed: 8 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ futures = { version = "0.3.32" }
8282
uuid = { version = "1.22.0", features = ["v4"] }
8383

8484
# db
85-
redis = { version = "1.0.3", default-features = false, features = [
85+
redis = { version = "1.1.0", default-features = false, features = [
8686
"tokio-comp",
8787
"connection-manager",
8888
] }

Settings.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ mercuryo:
3232
secret: ""
3333
token: ""
3434
banxa:
35-
url: ""
35+
url: "https://gemwallet.banxa.com"
3636
key:
37-
public: ""
37+
public: ""
3838
secret: ""
3939
paybis:
4040
key:
@@ -254,6 +254,9 @@ consumer:
254254

255255
api:
256256
service: ""
257+
admin:
258+
enabled: false
259+
token: ""
257260
auth:
258261
enabled: true
259262
tolerance: 5m

apps/api/src/admin.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
use rocket::Request;
2+
use rocket::http::Status;
3+
use rocket::outcome::Outcome::{Error, Success};
4+
use rocket::request::{FromRequest, Outcome};
5+
6+
use crate::responders::cache_error;
7+
8+
const AUTHORIZATION_HEADER: &str = "Authorization";
9+
const BEARER_PREFIX: &str = "Bearer ";
10+
11+
fn error_outcome<T>(req: &Request<'_>, status: Status, message: &str) -> Outcome<T, String> {
12+
cache_error(req, message);
13+
Error((status, message.to_string()))
14+
}
15+
16+
#[derive(Debug, Clone)]
17+
pub struct AdminConfig {
18+
pub token: String,
19+
}
20+
21+
pub struct AdminAuthorized;
22+
23+
#[rocket::async_trait]
24+
impl<'r> FromRequest<'r> for AdminAuthorized {
25+
type Error = String;
26+
27+
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
28+
let Success(config) = req.guard::<&rocket::State<AdminConfig>>().await else {
29+
return error_outcome(req, Status::InternalServerError, "Admin config not available");
30+
};
31+
32+
if config.token.is_empty() {
33+
return error_outcome(req, Status::InternalServerError, "Admin token is not configured");
34+
}
35+
36+
let Some(auth_value) = req.headers().get_one(AUTHORIZATION_HEADER) else {
37+
return error_outcome(req, Status::Unauthorized, "Missing Authorization header");
38+
};
39+
40+
if !auth_value.starts_with(BEARER_PREFIX) {
41+
return error_outcome(req, Status::Unauthorized, "Invalid authorization format");
42+
}
43+
44+
if auth_value[BEARER_PREFIX.len()..] != config.token {
45+
return error_outcome(req, Status::Unauthorized, "Invalid admin token");
46+
}
47+
48+
Success(AdminAuthorized)
49+
}
50+
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use rocket::http::{Header, Status};
55+
use rocket::local::asynchronous::Client;
56+
use rocket::{Build, Rocket, get, routes};
57+
58+
use super::{AdminAuthorized, AdminConfig};
59+
60+
#[get("/protected")]
61+
async fn protected(_admin: AdminAuthorized) -> &'static str {
62+
"ok"
63+
}
64+
65+
fn rocket(config: AdminConfig) -> Rocket<Build> {
66+
rocket::build().manage(config).mount("/", routes![protected])
67+
}
68+
69+
fn bearer_header(token: &str) -> Header<'static> {
70+
Header::new("Authorization", format!("Bearer {token}"))
71+
}
72+
73+
#[rocket::async_test]
74+
async fn test_invalid_token_returns_unauthorized() {
75+
let client = Client::tracked(rocket(AdminConfig { token: "secret".to_string() })).await.unwrap();
76+
77+
let response = client.get("/protected").header(bearer_header("wrong")).dispatch().await;
78+
79+
assert_eq!(response.status(), Status::Unauthorized);
80+
}
81+
82+
#[rocket::async_test]
83+
async fn test_correct_token_returns_ok() {
84+
let client = Client::tracked(rocket(AdminConfig { token: "secret".to_string() })).await.unwrap();
85+
86+
let response = client.get("/protected").header(bearer_header("secret")).dispatch().await;
87+
88+
assert_eq!(response.status(), Status::Ok);
89+
}
90+
91+
#[rocket::async_test]
92+
async fn test_enabled_with_empty_token_returns_internal_server_error() {
93+
let client = Client::tracked(rocket(AdminConfig { token: String::new() })).await.unwrap();
94+
95+
let response = client.get("/protected").dispatch().await;
96+
97+
assert_eq!(response.status(), Status::InternalServerError);
98+
}
99+
}

apps/api/src/assets/mod.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ pub mod cilent;
22
mod filter;
33
mod model;
44

5+
use crate::admin::AdminAuthorized;
6+
use crate::chain::ChainClient;
57
use crate::params::{AssetIdParam, SearchQueryParam};
68
use crate::responders::{ApiError, ApiResponse};
79
pub use cilent::{AssetsClient, SearchClient};
@@ -30,20 +32,20 @@ pub async fn get_assets(asset_ids: Json<Vec<String>>, client: &State<Mutex<Asset
3032

3133
#[post("/assets/add", format = "json", data = "<asset_id>")]
3234
pub async fn add_asset(
33-
asset_id: Json<Vec<AssetId>>,
35+
_admin: AdminAuthorized,
36+
asset_id: Json<AssetId>,
3437
client: &State<Mutex<AssetsClient>>,
35-
chain_client: &State<Mutex<crate::chain::ChainClient>>,
36-
) -> Result<ApiResponse<Vec<Asset>>, ApiError> {
37-
let asset_id = asset_id.0.first().ok_or(ApiError::BadRequest("Missing asset_id".to_string()))?;
38-
38+
chain_client: &State<Mutex<ChainClient>>,
39+
) -> Result<ApiResponse<Asset>, ApiError> {
40+
let asset_id = asset_id.0;
3941
let asset = chain_client
4042
.lock()
4143
.await
4244
.get_token_data(asset_id.chain, asset_id.token_id.clone().ok_or(ApiError::BadRequest("Missing token_id".to_string()))?)
4345
.await?;
4446
client.lock().await.add_assets(vec![asset.clone()])?;
4547

46-
Ok(vec![asset].into())
48+
Ok(asset.into())
4749
}
4850

4951
#[get("/assets/search?<query>&<chains>&<tags>&<limit>&<offset>")]

apps/api/src/chain/transaction.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
1-
use rocket::{State, get, tokio::sync::Mutex};
1+
use rocket::serde::json::Json;
2+
use rocket::{State, get, post, tokio::sync::Mutex};
3+
use streamer::{StreamProducer, StreamProducerQueue, TransactionsPayload};
24

5+
use crate::admin::AdminAuthorized;
36
use crate::params::ChainParam;
47
use crate::responders::{ApiError, ApiResponse};
5-
use primitives::Transaction;
8+
use primitives::{Transaction, TransactionId};
69

710
use super::ChainClient;
811

912
#[get("/chain/transactions/<chain>/<hash>")]
1013
pub async fn get_transaction(chain: ChainParam, hash: &str, client: &State<Mutex<ChainClient>>) -> Result<ApiResponse<Option<Transaction>>, ApiError> {
1114
Ok(client.lock().await.get_transaction_by_hash(chain.0, hash.to_string()).await?.into())
1215
}
16+
17+
#[post("/transactions/add", format = "json", data = "<transaction_id>")]
18+
pub async fn add_transaction(
19+
_admin: AdminAuthorized,
20+
transaction_id: Json<TransactionId>,
21+
chain_client: &State<Mutex<ChainClient>>,
22+
stream_producer: &State<StreamProducer>,
23+
) -> Result<ApiResponse<Option<Transaction>>, ApiError> {
24+
let client = chain_client.lock().await;
25+
26+
let transaction_id = transaction_id.0;
27+
let transaction = client.get_transaction_by_hash(transaction_id.chain, transaction_id.hash).await?;
28+
29+
if let Some(transaction) = transaction.as_ref() {
30+
let payload = TransactionsPayload::new(transaction.asset_id.chain, vec![], vec![transaction.clone()]);
31+
stream_producer.publish_transactions(payload).await?;
32+
}
33+
34+
Ok(transaction.into())
35+
}

apps/api/src/devices/clients/fiat.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
22
use std::error::Error;
33

44
use fiat::FiatClient;
5-
use primitives::{FiatQuote, FiatQuoteRequest, FiatQuoteUrl, FiatQuotes};
5+
use primitives::{FiatQuote, FiatQuoteRequest, FiatQuoteUrl, FiatQuotes, FiatTransaction, FiatTransactionInfo};
66
use storage::{Database, FiatRepository, WalletsRepository};
77

88
pub struct FiatQuotesClient {
@@ -19,6 +19,10 @@ impl FiatQuotesClient {
1919
self.fiat_client.get_quotes(request).await
2020
}
2121

22+
pub async fn get_quote(&self, quote_id: &str) -> Result<FiatQuote, Box<dyn Error + Send + Sync>> {
23+
self.fiat_client.get_quote(quote_id).await
24+
}
25+
2226
pub async fn get_quote_url(
2327
&self,
2428
quote_id: &str,
@@ -42,15 +46,19 @@ impl FiatQuotesClient {
4246
self.fiat_client.process_and_publish_webhook(provider, webhook_data).await
4347
}
4448

45-
pub async fn get_order_status(&self, provider: &str, order_id: &str) -> Result<primitives::FiatTransaction, Box<dyn Error + Send + Sync>> {
49+
pub async fn get_order_status(&self, provider: &str, order_id: &str) -> Result<FiatTransaction, Box<dyn Error + Send + Sync>> {
4650
self.fiat_client.get_order_status(provider, order_id).await
4751
}
4852

49-
pub fn get_transactions_by_wallet_id(&self, device_row_id: i32, wallet_id: i32) -> Result<Vec<primitives::FiatTransaction>, Box<dyn Error + Send + Sync>> {
53+
pub fn get_transactions_by_wallet_id(&self, device_row_id: i32, wallet_id: i32) -> Result<Vec<FiatTransactionInfo>, Box<dyn Error + Send + Sync>> {
5054
let subscriptions = self.database.wallets()?.get_subscriptions_by_wallet_id(device_row_id, wallet_id)?;
5155
let addresses = subscriptions.into_iter().map(|(_, address)| address.address).collect::<BTreeSet<_>>().into_iter().collect();
5256

53-
let mut database = self.database.fiat()?;
54-
Ok(FiatRepository::get_fiat_transactions_by_addresses(&mut database, addresses)?)
57+
let transactions = FiatRepository::get_fiat_transactions_with_assets_by_addresses(&mut self.database.fiat()?, addresses)?;
58+
59+
Ok(transactions
60+
.into_iter()
61+
.map(|(transaction, asset)| fiat::fiat_transaction_info(transaction, asset))
62+
.collect())
5563
}
5664
}

apps/api/src/devices/mod.rs

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ use primitives::device::Device;
2727
use primitives::name::NameRecord;
2828
use primitives::rewards::{RedemptionRequest, RedemptionResult, RewardRedemptionOption};
2929
use primitives::{
30-
AddressName, AssetId, AuthNonce, ChainAddress, FiatAssets, FiatQuoteRequest, FiatQuoteType, FiatQuoteUrl, FiatQuotes, InAppNotification, MigrateDeviceIdRequest, NFTData,
31-
PortfolioAssets, PortfolioAssetsRequest, PriceAlerts, ReportNft, RewardEvent, Rewards, ScanTransaction, ScanTransactionPayload, Transaction, TransactionsResponse,
30+
AddressName, AssetId, AuthNonce, ChainAddress, FiatAssets, FiatQuote, FiatQuoteRequest, FiatQuoteType, FiatQuoteUrl, FiatQuotes, InAppNotification, MigrateDeviceIdRequest,
31+
NFTData, PortfolioAssets, PortfolioAssetsRequest, PriceAlerts, ReportNft, RewardEvent, Rewards, ScanTransaction, ScanTransactionPayload, Transaction, TransactionsResponse,
3232
WalletSubscriptionChains,
3333
};
3434
use rocket::{State, delete, get, post, put, serde::json::Json, tokio::sync::Mutex};
@@ -218,15 +218,16 @@ pub async fn send_push_notification_device_v2(device: AuthenticatedDevice, clien
218218

219219
#[post("/devices/nft/report", format = "json", data = "<request>")]
220220
pub async fn report_device_nft_v2(device: AuthenticatedDevice, request: Json<ReportNft>, client: &State<Mutex<NFTClient>>) -> Result<ApiResponse<bool>, ApiError> {
221+
let asset_id = request
222+
.asset_id
223+
.as_deref()
224+
.map(|asset_id| AssetId::new(asset_id).ok_or_else(|| ApiError::BadRequest(format!("Invalid asset_id: {asset_id}"))))
225+
.transpose()?;
226+
221227
Ok(client
222228
.lock()
223229
.await
224-
.report_nft(
225-
&device.device_row.device_id,
226-
request.collection_id.clone(),
227-
request.asset_id.clone(),
228-
request.reason.clone(),
229-
)?
230+
.report_nft(&device.device_row.device_id, request.collection_id.clone(), asset_id, request.reason.clone())?
230231
.into())
231232
}
232233

@@ -328,29 +329,12 @@ pub async fn delete_device_price_alerts_v2(
328329
Ok(client.lock().await.delete_price_alerts(&device.device_row.device_id, price_alerts.0).await?.into())
329330
}
330331

331-
#[get("/devices/fiat/orders/<provider>/<order_id>")]
332-
pub async fn get_device_fiat_order_v2(
333-
_device: AuthenticatedDevice,
334-
provider: &str,
335-
order_id: &str,
336-
client: &State<Mutex<FiatQuotesClient>>,
337-
) -> Result<ApiResponse<primitives::FiatTransactionInfo>, ApiError> {
338-
Ok(fiat::fiat_transaction_info(client.lock().await.get_order_status(provider, order_id).await?).into())
339-
}
340-
341332
#[get("/devices/fiat/transactions")]
342333
pub async fn get_device_fiat_transactions_v2(
343334
device: AuthenticatedDeviceWallet,
344335
client: &State<Mutex<FiatQuotesClient>>,
345336
) -> Result<ApiResponse<Vec<primitives::FiatTransactionInfo>>, ApiError> {
346-
Ok(client
347-
.lock()
348-
.await
349-
.get_transactions_by_wallet_id(device.device_row.id, device.wallet_id)?
350-
.into_iter()
351-
.map(fiat::fiat_transaction_info)
352-
.collect::<Vec<_>>()
353-
.into())
337+
Ok(client.lock().await.get_transactions_by_wallet_id(device.device_row.id, device.wallet_id)?.into())
354338
}
355339

356340
#[get("/devices/fiat/assets/<quote_type>")]
@@ -385,14 +369,19 @@ pub async fn get_fiat_quotes_v2(
385369
quote_type: quote_type.0,
386370
amount,
387371
currency: currency.as_string(),
388-
provider_id: provider.map(|p| p.0.id()),
372+
provider_id: provider.map(|p| p.0.id().to_string()),
389373
ip_address: ip_address.map(str::to_string).unwrap_or(fallback_ip_address),
390374
};
391375
let quotes = client.lock().await.get_quotes(quote_request).await?;
392376
fiat_metrics.record_quotes(&quotes);
393377
Ok(quotes.into())
394378
}
395379

380+
#[get("/devices/fiat/quotes/<quote_id>", rank = 2)]
381+
pub async fn get_fiat_quote_v2(_device: AuthenticatedDevice, quote_id: &str, client: &State<Mutex<FiatQuotesClient>>) -> Result<ApiResponse<FiatQuote>, ApiError> {
382+
Ok(client.lock().await.get_quote(quote_id).await?.into())
383+
}
384+
396385
#[get("/fiat/quotes/<quote_type>?<asset_id>&<amount>&<currency>&<provider_id>&<ip_address>")]
397386
pub async fn get_fiat_quotes_v1(
398387
quote_type: FiatQuoteTypeParam,
@@ -411,7 +400,7 @@ pub async fn get_fiat_quotes_v1(
411400
quote_type: quote_type.0,
412401
amount,
413402
currency: currency.as_string(),
414-
provider_id: provider_id.map(|p| p.0.id()),
403+
provider_id: provider_id.map(|p| p.0.id().to_string()),
415404
ip_address: ip_address.map(str::to_string).unwrap_or(fallback_ip_address),
416405
};
417406
let quotes = client.lock().await.get_quotes(quote_request).await?;

0 commit comments

Comments
 (0)