#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod crypto;
mod handlers;
mod store;
use std::sync::{Arc, OnceLock};
use time::Duration;
use cratefield_core::{
Action, AnyError, Audience, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations,
Module, ModuleContext, Outcome, PersonalDataSet, Port, RandomBytes, RoutePolicy, SqlMigration,
Surface,
};
pub use crypto::{
AddressError, ContractSignatureVerifier, ContractVerifierError, MessageError,
StaticContractVerifier, VerifyError,
};
pub const DEFAULT_NONCE_TTL: Duration = Duration::seconds(600);
pub const DEFAULT_EVM_CHAIN_ID: &str = "1";
pub const SOLANA_CHAIN_ID: &str = "mainnet-beta";
pub const MESSAGE_VERSION: &str = "1";
const MIGRATION_INIT: SqlMigration = SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chain {
Evm,
Solana,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown chain {0:?}: expected \"evm\" or \"solana\"")]
pub struct UnknownChain(pub String);
impl Chain {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Evm => "evm",
Self::Solana => "solana",
}
}
}
impl std::fmt::Display for Chain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Chain {
type Err = UnknownChain;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"evm" => Ok(Self::Evm),
"solana" => Ok(Self::Solana),
other => Err(UnknownChain(other.to_owned())),
}
}
}
pub struct Wallets {
settings: handlers::Settings,
}
impl Default for Wallets {
fn default() -> Self {
Self::new()
}
}
impl Wallets {
#[must_use]
pub fn builder() -> WalletsBuilder {
WalletsBuilder::new()
}
#[must_use]
pub fn new() -> Self {
WalletsBuilder::new().build()
}
}
pub struct WalletsBuilder {
settings: handlers::Settings,
}
impl Default for WalletsBuilder {
fn default() -> Self {
Self::new()
}
}
impl WalletsBuilder {
#[must_use]
pub fn new() -> Self {
Self {
settings: handlers::Settings {
domain: String::new(),
uri: String::new(),
statement: None,
version: MESSAGE_VERSION.to_owned(),
chain_id_evm: DEFAULT_EVM_CHAIN_ID.to_owned(),
chain_id_solana: SOLANA_CHAIN_ID.to_owned(),
nonce_ttl_secs: DEFAULT_NONCE_TTL.whole_seconds(),
random: None,
contract_verifier: None,
},
}
}
#[must_use]
pub fn domain(mut self, domain: impl Into<String>) -> Self {
self.settings.domain = domain.into();
self
}
#[must_use]
pub fn uri(mut self, uri: impl Into<String>) -> Self {
self.settings.uri = uri.into();
self
}
#[must_use]
pub fn statement(mut self, statement: impl Into<String>) -> Self {
self.settings.statement = Some(statement.into());
self
}
#[must_use]
pub fn chain_id(mut self, chain_id: impl Into<String>) -> Self {
self.settings.chain_id_evm = chain_id.into();
self
}
#[must_use]
pub fn nonce_ttl(mut self, ttl: Duration) -> Self {
self.settings.nonce_ttl_secs = ttl.whole_seconds().clamp(1, MAX_NONCE_TTL.whole_seconds());
self
}
#[must_use]
pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
self.settings.random = Some(Arc::new(random));
self
}
#[must_use]
pub fn contract_verifier(mut self, verifier: impl ContractSignatureVerifier + 'static) -> Self {
self.settings.contract_verifier = Some(Arc::new(verifier));
self
}
#[must_use]
pub fn build(mut self) -> Wallets {
if self.settings.uri.is_empty() && !self.settings.domain.is_empty() {
self.settings.uri = format!("https://{}", self.settings.domain);
}
Wallets {
settings: self.settings,
}
}
}
pub const MAX_NONCE_TTL: Duration = Duration::seconds(60 * 60);
impl Module for Wallets {
fn name(&self) -> &'static str {
"wallets"
}
fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn requires(&self) -> &'static [Port] {
&[Port::Db, Port::Auth]
}
fn optional(&self) -> &'static [Port] {
&[Port::Clock, Port::IdGen]
}
fn tables(&self) -> &'static [&'static str] {
&["wallet_nonces", "wallet_links"]
}
fn personal_data(&self) -> &'static [PersonalDataSet] {
static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
SETS.get_or_init(|| {
vec![
PersonalDataSet {
table: "wallet_nonces",
subject: "account_id",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "An outstanding single-use proof-of-ownership nonce: who \
requested it, which chain it was for, when it was issued and when it \
lapsed. The address it is eventually spent against is never stored \
here — it is in the signed message, not the row. Erasing the subject's \
rows deletes them; the scheduled purge removes every row past its \
expiry anyway, spent or not.",
redacted: &[],
subject_via: None,
},
PersonalDataSet {
table: "wallet_links",
subject: "account_id",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "A wallet the subject proved they control: the checksummed \
address, the chain it lives on, and when it was linked. The module \
stores no signature and no signature hash, so a row cannot be replayed \
as a proof of anything. Erasing the subject's rows deletes every wallet \
they linked.",
redacted: &[],
subject_via: None,
},
]
})
.as_slice()
}
fn migrations(&self) -> Migrations {
const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
Migrations::sqlite(&MIGRATIONS)
}
fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
Ok(())
}
fn self_check(&self) -> Vec<String> {
handlers::self_check(&self.settings)
}
fn router(&self, ctx: ModuleContext) -> axum::Router {
handlers::router(ctx, self.settings.clone())
}
fn surface(&self) -> Surface {
Surface::new()
.action(
Action::post("nonce", "/nonce")
.policy(RoutePolicy::Open)
.audience(Audience::Subject)
.outcome(Outcome::Json)
.input_schema(cratefield_core::schema_for::<handlers::NonceBody>())
.output::<handlers::NonceResponse>(),
)
.action(
Action::post("verify", "/verify")
.policy(RoutePolicy::Open)
.audience(Audience::Subject)
.outcome(Outcome::Json)
.input_schema(cratefield_core::schema_for::<handlers::VerifyBody>())
.output::<handlers::LinkResponse>(),
)
.action(
Action::get("list", "/")
.policy(RoutePolicy::Open)
.audience(Audience::Subject)
.outcome(Outcome::Json),
)
.action(
Action::delete("unlink", "/{id}")
.policy(RoutePolicy::Open)
.audience(Audience::Subject),
)
}
fn scheduled<'a>(
&'a self,
ctx: &'a ModuleContext,
cron: &'a str,
) -> BoxFuture<'a, Result<(), AnyError>> {
Box::pin(async move {
if !ctx.scheduled.try_spend(1) {
return Ok(());
}
let Some(db) = ctx.ports.db.clone() else {
return Ok(());
};
let now = handlers::stamp(handlers::now_of(ctx));
let deleted = store::purge_expired_nonces(&*db, &now)
.await
.map_err(|err| Box::new(err) as AnyError)?;
if deleted > 0 {
tracing::info!(deleted, cron, "purged expired wallet nonces");
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn module() -> Wallets {
Wallets::builder().domain("example.test").build()
}
#[test]
fn module_metadata() {
let module = module();
assert_eq!(module.name(), "wallets");
assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(module.requires(), [Port::Db, Port::Auth]);
assert_eq!(module.optional(), [Port::Clock, Port::IdGen]);
assert_eq!(module.tables(), ["wallet_nonces", "wallet_links"]);
assert_eq!(module.migrations().sqlite.len(), 1);
assert!(
module.migrations().postgres.is_empty(),
"sqlite migrations only"
);
assert!(!module.surface().is_empty());
}
#[test]
fn the_defaults_are_a_ten_minute_nonce_on_mainnet() {
let module = Wallets::new();
assert_eq!(module.settings.nonce_ttl_secs, 600);
assert_eq!(module.settings.chain_id_evm, "1");
assert_eq!(module.settings.chain_id_solana, SOLANA_CHAIN_ID);
assert_eq!(module.settings.version, "1");
assert!(module.settings.statement.is_none());
assert!(module.settings.contract_verifier.is_none());
}
#[test]
fn the_builder_sets_what_it_names() {
let module = Wallets::builder()
.domain("example.test")
.uri("https://example.test/connect")
.statement("Link this wallet.")
.chain_id("8453")
.nonce_ttl(Duration::seconds(120))
.build();
assert_eq!(module.settings.domain, "example.test");
assert_eq!(module.settings.uri, "https://example.test/connect");
assert_eq!(
module.settings.statement.as_deref(),
Some("Link this wallet.")
);
assert_eq!(module.settings.chain_id_evm, "8453");
assert_eq!(module.settings.nonce_ttl_secs, 120);
}
#[test]
fn the_uri_defaults_to_the_domain() {
let module = Wallets::builder().domain("example.test").build();
assert_eq!(module.settings.uri, "https://example.test");
}
#[test]
fn builder_values_are_clamped_not_honoured() {
let module = Wallets::builder().nonce_ttl(Duration::ZERO).build();
assert_eq!(module.settings.nonce_ttl_secs, 1);
let module = Wallets::builder()
.nonce_ttl(Duration::seconds(365 * 24 * 60 * 60))
.build();
assert_eq!(
module.settings.nonce_ttl_secs,
MAX_NONCE_TTL.whole_seconds()
);
}
#[test]
fn self_check_names_every_missing_piece() {
let text = Wallets::new().self_check().join("\n");
assert!(text.contains("no domain is set"), "{text}");
assert!(text.contains("no entropy source is set"), "{text}");
let text = Wallets::builder()
.domain("example.test")
.build()
.self_check()
.join("\n");
assert!(!text.contains("no domain is set"), "{text}");
assert!(text.contains("no entropy source is set"), "{text}");
}
#[test]
fn a_chain_round_trips_through_its_wire_name() {
assert_eq!("evm".parse::<Chain>().unwrap(), Chain::Evm);
assert_eq!("solana".parse::<Chain>().unwrap(), Chain::Solana);
assert_eq!(Chain::Evm.as_str(), "evm");
assert_eq!(Chain::Solana.to_string(), "solana");
assert_eq!(
"EVM".parse::<Chain>().unwrap_err(),
UnknownChain("EVM".to_owned())
);
}
#[test]
fn a_contract_verifier_makes_the_module_compose() {
let module = Wallets::builder()
.domain("example.test")
.contract_verifier(StaticContractVerifier::new(
"0x52908400098527886E0F7030069857D2E4169EE7".to_owned(),
"a message",
))
.build();
assert!(module.settings.contract_verifier.is_some());
}
}