Multi-currency Phase 4 — rate fetcher with Kraken/Coinbase/CoinGecko fallback

src/rates.rs adds an in-memory rate cache (60s TTL) with a 3-source
fallback chain. AppState gains `rates: Arc<RateCache>`. Manual pins
via the settings table override the chain — used by tests for
deterministic conversions and by operators during maintenance
windows.

Admin endpoints:
- GET /v1/admin/rates: cache snapshot
- POST /v1/admin/rates/refresh: force re-fetch (audit-logged)

Two new tests (network-free, manual-pin path):
- rate_cache_honors_manual_pin_from_settings
- admin_rates_endpoint_reflects_manual_pin

Test count: 36 (was 34).
This commit is contained in:
Grant
2026-05-08 12:16:22 -05:00
parent 356d17fdde
commit eb885502ba
7 changed files with 459 additions and 0 deletions
+78
View File
@@ -113,6 +113,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
reason: "test fixture".into(),
})),
rates: keysat::rates::RateCache::new(),
};
(state, tmp)
}
@@ -1139,6 +1140,83 @@ async fn recover_returns_license_key_for_matching_pair() {
assert_eq!(audit_count, 1, "recovery must write an audit row");
}
/// Rate fetcher: manual pin in settings table overrides the source
/// chain. Locks in the test-mode + maintenance-window contract that
/// other phases (invoice rate recording, buy-page rendering) rely on.
#[tokio::test]
async fn rate_cache_honors_manual_pin_from_settings() {
let (state, _tmp) = make_test_state().await;
// Pin USD at $65,000 / BTC. The fetcher MUST return this value
// without hitting any external API.
sqlx::query("INSERT INTO settings(key, value, updated_at) VALUES('manual_rate_pin_USD', '65000', ?)")
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
let rate = keysat::rates::get_rate(&state, "USD")
.await
.expect("manual pin should resolve without network");
assert_eq!(rate.units_per_btc, 65000.0);
assert_eq!(rate.source, "manual_pin");
// Convert $49.00 (4900 cents) to sats. At $65k/BTC:
// sats = 4900 * 1_000_000 / 65000 = 75,384.6 → 75,385.
let conv = keysat::rates::convert_to_sats(&state, "USD", 4900)
.await
.expect("convert");
assert_eq!(conv.sats, 75_385, "rounding tie-break: 75384.615 rounds to 75385");
assert_eq!(
conv.rate_centibps,
Some(650_000_000),
"rate stored as units×10000: 65000 × 10000"
);
// SAT-currency conversions are identity (no rate involved).
let sat_conv = keysat::rates::convert_to_sats(&state, "SAT", 50_000)
.await
.unwrap();
assert_eq!(sat_conv.sats, 50_000);
assert!(sat_conv.rate_centibps.is_none());
}
/// Admin endpoint visibility: GET /v1/admin/rates returns whatever
/// is currently cached, including manual pins. Operators can verify
/// the daemon's current quote against external sources before
/// trusting fiat-priced invoice flows.
#[tokio::test]
async fn admin_rates_endpoint_reflects_manual_pin() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
sqlx::query("INSERT INTO settings(key, value, updated_at) VALUES('manual_rate_pin_USD', '60000', ?)")
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
// Trigger a rate read so the cache populates.
let _ = keysat::rates::get_rate(&state, "USD").await.unwrap();
let req = build_request(
"GET",
"/v1/admin/rates",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let rates = body["rates"].as_array().expect("rates array");
let usd = rates
.iter()
.find(|r| r["currency"] == "USD")
.expect("USD entry should be present");
assert_eq!(usd["units_per_btc"], 60_000.0);
assert_eq!(usd["source"], "manual_pin");
}
/// Multi-currency product creation. The admin endpoint accepts both
/// the legacy SAT-only form (`price_sats: N`) and the new typed form
/// (`price_currency + price_value`). Verifies: