diff --git a/src/api/openapi.rs b/src/api/openapi.rs index bc2ded3..5bbfdd3 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -56,6 +56,7 @@ use utoipa::OpenApi; crate::api::wallet::grin::scan, crate::api::wallet::grin::height, crate::api::wallet::grin::broadcast, + crate::api::wallet::grin::address_user, crate::api::wallet::grin_relay::create, crate::api::wallet::grin_relay::pending, crate::api::wallet::grin_relay::get_relay, @@ -144,6 +145,7 @@ use utoipa::OpenApi; crate::api::wallet::grin::GrinHeightResponse, crate::api::wallet::grin::GrinBroadcastRequest, crate::api::wallet::grin::GrinBroadcastResponse, + crate::api::wallet::grin::GrinAddressUserResponse, crate::api::wallet::grin_relay::CreateRelayRequest, crate::api::wallet::grin_relay::RespondRelayRequest, crate::api::wallet::grin_relay::FinalizeRelayRequest, diff --git a/src/api/wallet/grin.rs b/src/api/wallet/grin.rs index fa3c613..6fe45a4 100644 --- a/src/api/wallet/grin.rs +++ b/src/api/wallet/grin.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use axum::{ - extract::State, + extract::{Path, State}, http::HeaderMap, routing::{get, post}, Json, Router, @@ -93,6 +93,31 @@ pub struct GrinBroadcastResponse { pub ok: bool, } +/// Resolution of a bare Grin address to its owning user's routing identity. +/// +/// The address→npub **bridge**: given a `grin1…` slatepack address, report +/// whether it belongs to a user registered on *this* backend and, if so, that +/// user's linked Nostr pubkey so a bare-address send can be upgraded to a +/// federated Nostr gift-wrap instead of the same-instance relay. +/// +/// NOTE (federation): this is a **same-instance convenience**. It can only +/// resolve addresses whose owner registered their Grin key here (via `POST +/// /keys`). It is *not* a federated directory — a wallet on another backend +/// won't be found. `registered: false` (a constant-shape miss, mirroring +/// `GET /users/by-username`) means "unknown to this instance", at which point +/// the sender falls back to manual clipboard / the backend relay. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GrinAddressUserResponse { + /// Whether the address resolved to a user on this backend. + pub registered: bool, + /// The owning user's id (UUID), when `registered`. + pub user_id: Option, + /// The owning user's linked Nostr pubkey (x-only hex), when they have one. + /// This is what the sender feeds the Nostr gift-wrap channel to route the + /// slate over Nostr. `None` when the user registered no Nostr identity. + pub npub: Option, +} + fn output_dto(o: ViewWalletOutputResult) -> GrinOutput { GrinOutput { commit: o.commit, @@ -200,6 +225,46 @@ pub async fn broadcast( Ok(Json(GrinBroadcastResponse { ok: true })) } +/// Resolve a bare Grin address to its owning user's Nostr routing identity. +/// +/// The address→npub bridge (see [`GrinAddressUserResponse`]). JWT-gated: only +/// authenticated senders resolve routing. An address unknown to this backend +/// returns the constant-shape `registered: false` response — the caller then +/// falls back to manual clipboard or the same-instance relay. +#[utoipa::path( + security(("bearer_auth" = [])), + get, + path = "/wallet/grin/address/{addr}/user", + params(("addr" = String, Path, description = "Grin address (registered public key)")), + responses( + (status = 200, description = "Address resolution result", body = GrinAddressUserResponse), + (status = 401, description = "Missing or invalid token") + ), + tag = "grin" +)] +#[instrument(skip(state, headers))] +pub async fn address_user( + State(state): State>, + headers: HeaderMap, + Path(addr): Path, +) -> Result, AppError> { + extract_user_id_from_token(&state, &headers).await?; + + let Some(user) = state.db.find_user_by_grin_address(&addr).await? else { + return Ok(Json(GrinAddressUserResponse { + registered: false, + user_id: None, + npub: None, + })); + }; + + Ok(Json(GrinAddressUserResponse { + registered: true, + user_id: Some(user.id.to_string()), + npub: user.nostr_pubkey, + })) +} + // ── router ──────────────────────────────────────────────────────────────────── /// Grin routes, RELATIVE to the `/api/v1` mount point. @@ -208,4 +273,5 @@ pub fn routes() -> Router> { .route("/wallet/grin/scan", post(scan)) .route("/wallet/grin/height", get(height)) .route("/wallet/grin/broadcast", post(broadcast)) + .route("/wallet/grin/address/:addr/user", get(address_user)) } diff --git a/src/api/wallet/grin_relay.rs b/src/api/wallet/grin_relay.rs index 23ab174..2524747 100644 --- a/src/api/wallet/grin_relay.rs +++ b/src/api/wallet/grin_relay.rs @@ -19,6 +19,23 @@ //! must be a registered user (no blind spam to arbitrary addresses); slatepack //! sizes are capped; entries expire; and the whole feature is behind //! `FEATURE_GRIN_RELAY` so an operator can disable the mailbox. +//! +//! ## Federation: this relay is a SAME-INSTANCE CONVENIENCE, not the default +//! +//! The relay only connects two users **registered on the same backend**: the +//! sender is the JWT caller and the recipient is addressed by a +//! `recipient_user_id` that must already exist in *this* instance's `users` +//! table (there is deliberately no cross-instance address lookup here). A wallet +//! on another backend cannot be a relay counterparty. +//! +//! It is therefore NOT the federated transport. The **default, federated** +//! transport for interactive Grin transfers is Nostr gift-wrap (npub / NIP-05), +//! which works across instances; **manual clipboard** is the universal fallback. +//! Prefer those. This mailbox is offered only as an ergonomic shortcut when both +//! parties happen to live on the same instance — treat it as an optimization, +//! never as something a second backend can rely on. See the address→npub bridge +//! in [`super::grin`] (`/wallet/grin/address/{addr}/user`), which lets a +//! same-instance address be upgraded to federated Nostr routing. use std::sync::Arc; diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs index 60b090c..f127efa 100644 --- a/src/infra/db/users.rs +++ b/src/infra/db/users.rs @@ -222,6 +222,29 @@ impl Database { .await?) } + /// Resolve a registered Grin address to its owning user. + /// + /// The `address` is matched verbatim against `user_keys.public_key` for the + /// `grin` asset (the canonical form the wallet registered via `POST /keys`). + /// This is a **same-instance** bridge: it only resolves addresses whose owner + /// registered on *this* backend. Used to look up a recipient's linked Nostr + /// pubkey so a bare-`grin1…`-addressed send can route over Nostr gift-wrap. + #[instrument(skip(self, address))] + pub async fn find_user_by_grin_address( + &self, + address: &str, + ) -> Result, AppError> { + let sql = format!( + "SELECT {USER_COLS} FROM users u \ + JOIN user_keys k ON k.user_id = u.id \ + WHERE k.asset = 'grin' AND k.public_key = $1" + ); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(address) + .fetch_optional(self.pool()) + .await?) + } + #[instrument(skip(self))] pub async fn get_user_by_username(&self, username: &str) -> Result, AppError> { let sql = format!("SELECT {USER_COLS} FROM users WHERE username = $1");