cratefield_module_wallets/
lib.rs1#![doc = include_str!("../README.md")]
41#![forbid(unsafe_code)]
42
43mod crypto;
44mod handlers;
45mod store;
46
47use std::sync::{Arc, OnceLock};
48
49use time::Duration;
50
51use cratefield_core::{
52 Action, AnyError, Audience, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations,
53 Module, ModuleContext, Outcome, PersonalDataSet, Port, RandomBytes, RoutePolicy, SqlMigration,
54 Surface,
55};
56
57pub use crypto::{
58 AddressError, ContractSignatureVerifier, ContractVerifierError, MessageError,
59 StaticContractVerifier, VerifyError,
60};
61
62pub const DEFAULT_NONCE_TTL: Duration = Duration::seconds(600);
66
67pub const DEFAULT_EVM_CHAIN_ID: &str = "1";
71
72pub const SOLANA_CHAIN_ID: &str = "mainnet-beta";
75
76pub const MESSAGE_VERSION: &str = "1";
78
79const MIGRATION_INIT: SqlMigration = SqlMigration::new(
82 "0001",
83 "init",
84 include_str!("../migrations/sqlite/0001_init.sql"),
85);
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Chain {
93 Evm,
95 Solana,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
101#[error("unknown chain {0:?}: expected \"evm\" or \"solana\"")]
102pub struct UnknownChain(pub String);
103
104impl Chain {
105 #[must_use]
107 pub fn as_str(self) -> &'static str {
108 match self {
109 Self::Evm => "evm",
110 Self::Solana => "solana",
111 }
112 }
113}
114
115impl std::fmt::Display for Chain {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.write_str(self.as_str())
118 }
119}
120
121impl std::str::FromStr for Chain {
123 type Err = UnknownChain;
124
125 fn from_str(raw: &str) -> Result<Self, Self::Err> {
126 match raw {
127 "evm" => Ok(Self::Evm),
128 "solana" => Ok(Self::Solana),
129 other => Err(UnknownChain(other.to_owned())),
130 }
131 }
132}
133
134pub struct Wallets {
139 settings: handlers::Settings,
140}
141
142impl Default for Wallets {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl Wallets {
149 #[must_use]
153 pub fn builder() -> WalletsBuilder {
154 WalletsBuilder::new()
155 }
156
157 #[must_use]
159 pub fn new() -> Self {
160 WalletsBuilder::new().build()
161 }
162}
163
164pub struct WalletsBuilder {
168 settings: handlers::Settings,
169}
170
171impl Default for WalletsBuilder {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177impl WalletsBuilder {
178 #[must_use]
181 pub fn new() -> Self {
182 Self {
183 settings: handlers::Settings {
184 domain: String::new(),
185 uri: String::new(),
186 statement: None,
187 version: MESSAGE_VERSION.to_owned(),
188 chain_id_evm: DEFAULT_EVM_CHAIN_ID.to_owned(),
189 chain_id_solana: SOLANA_CHAIN_ID.to_owned(),
190 nonce_ttl_secs: DEFAULT_NONCE_TTL.whole_seconds(),
191 random: None,
192 contract_verifier: None,
193 },
194 }
195 }
196
197 #[must_use]
201 pub fn domain(mut self, domain: impl Into<String>) -> Self {
202 self.settings.domain = domain.into();
203 self
204 }
205
206 #[must_use]
209 pub fn uri(mut self, uri: impl Into<String>) -> Self {
210 self.settings.uri = uri.into();
211 self
212 }
213
214 #[must_use]
218 pub fn statement(mut self, statement: impl Into<String>) -> Self {
219 self.settings.statement = Some(statement.into());
220 self
221 }
222
223 #[must_use]
226 pub fn chain_id(mut self, chain_id: impl Into<String>) -> Self {
227 self.settings.chain_id_evm = chain_id.into();
228 self
229 }
230
231 #[must_use]
234 pub fn nonce_ttl(mut self, ttl: Duration) -> Self {
235 self.settings.nonce_ttl_secs = ttl.whole_seconds().clamp(1, MAX_NONCE_TTL.whole_seconds());
236 self
237 }
238
239 #[must_use]
242 pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
243 self.settings.random = Some(Arc::new(random));
244 self
245 }
246
247 #[must_use]
251 pub fn contract_verifier(mut self, verifier: impl ContractSignatureVerifier + 'static) -> Self {
252 self.settings.contract_verifier = Some(Arc::new(verifier));
253 self
254 }
255
256 #[must_use]
260 pub fn build(mut self) -> Wallets {
261 if self.settings.uri.is_empty() && !self.settings.domain.is_empty() {
262 self.settings.uri = format!("https://{}", self.settings.domain);
263 }
264 Wallets {
265 settings: self.settings,
266 }
267 }
268}
269
270pub const MAX_NONCE_TTL: Duration = Duration::seconds(60 * 60);
273
274impl Module for Wallets {
275 fn name(&self) -> &'static str {
276 "wallets"
277 }
278
279 fn version(&self) -> &'static str {
280 env!("CARGO_PKG_VERSION")
281 }
282
283 fn requires(&self) -> &'static [Port] {
287 &[Port::Db, Port::Auth]
288 }
289
290 fn optional(&self) -> &'static [Port] {
293 &[Port::Clock, Port::IdGen]
294 }
295
296 fn tables(&self) -> &'static [&'static str] {
297 &["wallet_nonces", "wallet_links"]
298 }
299
300 fn personal_data(&self) -> &'static [PersonalDataSet] {
313 static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
314 SETS.get_or_init(|| {
315 vec![
316 PersonalDataSet {
317 table: "wallet_nonces",
318 subject: "account_id",
319 kind: DataKind::Identifier,
320 disposition: Disposition::Erase,
321 description: "An outstanding single-use proof-of-ownership nonce: who \
322 requested it, which chain it was for, when it was issued and when it \
323 lapsed. The address it is eventually spent against is never stored \
324 here — it is in the signed message, not the row. Erasing the subject's \
325 rows deletes them; the scheduled purge removes every row past its \
326 expiry anyway, spent or not.",
327 redacted: &[],
328 subject_via: None,
329 },
330 PersonalDataSet {
331 table: "wallet_links",
332 subject: "account_id",
333 kind: DataKind::Identifier,
334 disposition: Disposition::Erase,
335 description: "A wallet the subject proved they control: the checksummed \
336 address, the chain it lives on, and when it was linked. The module \
337 stores no signature and no signature hash, so a row cannot be replayed \
338 as a proof of anything. Erasing the subject's rows deletes every wallet \
339 they linked.",
340 redacted: &[],
341 subject_via: None,
342 },
343 ]
344 })
345 .as_slice()
346 }
347
348 fn migrations(&self) -> Migrations {
349 const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
350 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
351 Migrations::sqlite(&MIGRATIONS)
352 }
353
354 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
358 Ok(())
359 }
360
361 fn self_check(&self) -> Vec<String> {
362 handlers::self_check(&self.settings)
363 }
364
365 fn router(&self, ctx: ModuleContext) -> axum::Router {
366 handlers::router(ctx, self.settings.clone())
367 }
368
369 fn surface(&self) -> Surface {
380 Surface::new()
381 .action(
382 Action::post("nonce", "/nonce")
383 .policy(RoutePolicy::Open)
384 .audience(Audience::Subject)
385 .outcome(Outcome::Json)
386 .input_schema(cratefield_core::schema_for::<handlers::NonceBody>())
387 .output::<handlers::NonceResponse>(),
388 )
389 .action(
390 Action::post("verify", "/verify")
391 .policy(RoutePolicy::Open)
392 .audience(Audience::Subject)
393 .outcome(Outcome::Json)
394 .input_schema(cratefield_core::schema_for::<handlers::VerifyBody>())
395 .output::<handlers::LinkResponse>(),
396 )
397 .action(
398 Action::get("list", "/")
399 .policy(RoutePolicy::Open)
400 .audience(Audience::Subject)
401 .outcome(Outcome::Json),
402 )
403 .action(
404 Action::delete("unlink", "/{id}")
405 .policy(RoutePolicy::Open)
406 .audience(Audience::Subject),
407 )
408 }
409
410 fn scheduled<'a>(
417 &'a self,
418 ctx: &'a ModuleContext,
419 cron: &'a str,
420 ) -> BoxFuture<'a, Result<(), AnyError>> {
421 Box::pin(async move {
422 if !ctx.scheduled.try_spend(1) {
423 return Ok(());
424 }
425 let Some(db) = ctx.ports.db.clone() else {
426 return Ok(());
427 };
428 let now = handlers::stamp(handlers::now_of(ctx));
429 let deleted = store::purge_expired_nonces(&*db, &now)
430 .await
431 .map_err(|err| Box::new(err) as AnyError)?;
432 if deleted > 0 {
433 tracing::info!(deleted, cron, "purged expired wallet nonces");
434 }
435 Ok(())
436 })
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 fn module() -> Wallets {
445 Wallets::builder().domain("example.test").build()
446 }
447
448 #[test]
449 fn module_metadata() {
450 let module = module();
451 assert_eq!(module.name(), "wallets");
452 assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
453 assert_eq!(module.requires(), [Port::Db, Port::Auth]);
454 assert_eq!(module.optional(), [Port::Clock, Port::IdGen]);
455 assert_eq!(module.tables(), ["wallet_nonces", "wallet_links"]);
456 assert_eq!(module.migrations().sqlite.len(), 1);
457 assert!(
458 module.migrations().postgres.is_empty(),
459 "sqlite migrations only"
460 );
461 assert!(!module.surface().is_empty());
462 }
463
464 #[test]
465 fn the_defaults_are_a_ten_minute_nonce_on_mainnet() {
466 let module = Wallets::new();
467 assert_eq!(module.settings.nonce_ttl_secs, 600);
468 assert_eq!(module.settings.chain_id_evm, "1");
469 assert_eq!(module.settings.chain_id_solana, SOLANA_CHAIN_ID);
470 assert_eq!(module.settings.version, "1");
471 assert!(module.settings.statement.is_none());
472 assert!(module.settings.contract_verifier.is_none());
473 }
474
475 #[test]
476 fn the_builder_sets_what_it_names() {
477 let module = Wallets::builder()
478 .domain("example.test")
479 .uri("https://example.test/connect")
480 .statement("Link this wallet.")
481 .chain_id("8453")
482 .nonce_ttl(Duration::seconds(120))
483 .build();
484 assert_eq!(module.settings.domain, "example.test");
485 assert_eq!(module.settings.uri, "https://example.test/connect");
486 assert_eq!(
487 module.settings.statement.as_deref(),
488 Some("Link this wallet.")
489 );
490 assert_eq!(module.settings.chain_id_evm, "8453");
491 assert_eq!(module.settings.nonce_ttl_secs, 120);
492 }
493
494 #[test]
495 fn the_uri_defaults_to_the_domain() {
496 let module = Wallets::builder().domain("example.test").build();
497 assert_eq!(module.settings.uri, "https://example.test");
498 }
499
500 #[test]
501 fn builder_values_are_clamped_not_honoured() {
502 let module = Wallets::builder().nonce_ttl(Duration::ZERO).build();
503 assert_eq!(module.settings.nonce_ttl_secs, 1);
504
505 let module = Wallets::builder()
506 .nonce_ttl(Duration::seconds(365 * 24 * 60 * 60))
507 .build();
508 assert_eq!(
509 module.settings.nonce_ttl_secs,
510 MAX_NONCE_TTL.whole_seconds()
511 );
512 }
513
514 #[test]
515 fn self_check_names_every_missing_piece() {
516 let text = Wallets::new().self_check().join("\n");
517 assert!(text.contains("no domain is set"), "{text}");
518 assert!(text.contains("no entropy source is set"), "{text}");
519
520 let text = Wallets::builder()
521 .domain("example.test")
522 .build()
523 .self_check()
524 .join("\n");
525 assert!(!text.contains("no domain is set"), "{text}");
526 assert!(text.contains("no entropy source is set"), "{text}");
527 }
528
529 #[test]
530 fn a_chain_round_trips_through_its_wire_name() {
531 assert_eq!("evm".parse::<Chain>().unwrap(), Chain::Evm);
532 assert_eq!("solana".parse::<Chain>().unwrap(), Chain::Solana);
533 assert_eq!(Chain::Evm.as_str(), "evm");
534 assert_eq!(Chain::Solana.to_string(), "solana");
535 assert_eq!(
536 "EVM".parse::<Chain>().unwrap_err(),
537 UnknownChain("EVM".to_owned())
538 );
539 }
540
541 #[test]
542 fn a_contract_verifier_makes_the_module_compose() {
543 let module = Wallets::builder()
544 .domain("example.test")
545 .contract_verifier(StaticContractVerifier::new(
546 "0x52908400098527886E0F7030069857D2E4169EE7".to_owned(),
547 "a message",
548 ))
549 .build();
550 assert!(module.settings.contract_verifier.is_some());
551 }
552}