feat(grin): address→npub bridge + mark relay same-instance-only

Enhance the grin address→user lookup to return the owner's linked npub (not just a
bool), so a bare grin slatepack address can resolve to its owner's npub and route
over the federated Nostr gift-wrap rail instead of only manual handoff. Doc-comment
the backend slatepack relay as a same-instance-only convenience — the federated
default transport is Nostr gift-wrap; the relay needs a recipient user_id and both
parties on this instance, so it is not the primary path in a public-backend world.
This commit is contained in:
jwinterm
2026-07-09 04:47:13 -04:00
parent 57ebb2a5aa
commit a7c4bbd238
4 changed files with 109 additions and 1 deletions
+2
View File
@@ -56,6 +56,7 @@ use utoipa::OpenApi;
crate::api::wallet::grin::scan, crate::api::wallet::grin::scan,
crate::api::wallet::grin::height, crate::api::wallet::grin::height,
crate::api::wallet::grin::broadcast, crate::api::wallet::grin::broadcast,
crate::api::wallet::grin::address_user,
crate::api::wallet::grin_relay::create, crate::api::wallet::grin_relay::create,
crate::api::wallet::grin_relay::pending, crate::api::wallet::grin_relay::pending,
crate::api::wallet::grin_relay::get_relay, crate::api::wallet::grin_relay::get_relay,
@@ -144,6 +145,7 @@ use utoipa::OpenApi;
crate::api::wallet::grin::GrinHeightResponse, crate::api::wallet::grin::GrinHeightResponse,
crate::api::wallet::grin::GrinBroadcastRequest, crate::api::wallet::grin::GrinBroadcastRequest,
crate::api::wallet::grin::GrinBroadcastResponse, crate::api::wallet::grin::GrinBroadcastResponse,
crate::api::wallet::grin::GrinAddressUserResponse,
crate::api::wallet::grin_relay::CreateRelayRequest, crate::api::wallet::grin_relay::CreateRelayRequest,
crate::api::wallet::grin_relay::RespondRelayRequest, crate::api::wallet::grin_relay::RespondRelayRequest,
crate::api::wallet::grin_relay::FinalizeRelayRequest, crate::api::wallet::grin_relay::FinalizeRelayRequest,
+67 -1
View File
@@ -17,7 +17,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
extract::State, extract::{Path, State},
http::HeaderMap, http::HeaderMap,
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
@@ -93,6 +93,31 @@ pub struct GrinBroadcastResponse {
pub ok: bool, 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<String>,
/// 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<String>,
}
fn output_dto(o: ViewWalletOutputResult) -> GrinOutput { fn output_dto(o: ViewWalletOutputResult) -> GrinOutput {
GrinOutput { GrinOutput {
commit: o.commit, commit: o.commit,
@@ -200,6 +225,46 @@ pub async fn broadcast(
Ok(Json(GrinBroadcastResponse { ok: true })) 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<Arc<AppState>>,
headers: HeaderMap,
Path(addr): Path<String>,
) -> Result<Json<GrinAddressUserResponse>, 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 ──────────────────────────────────────────────────────────────────── // ── router ────────────────────────────────────────────────────────────────────
/// Grin routes, RELATIVE to the `/api/v1` mount point. /// Grin routes, RELATIVE to the `/api/v1` mount point.
@@ -208,4 +273,5 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/wallet/grin/scan", post(scan)) .route("/wallet/grin/scan", post(scan))
.route("/wallet/grin/height", get(height)) .route("/wallet/grin/height", get(height))
.route("/wallet/grin/broadcast", post(broadcast)) .route("/wallet/grin/broadcast", post(broadcast))
.route("/wallet/grin/address/:addr/user", get(address_user))
} }
+17
View File
@@ -19,6 +19,23 @@
//! must be a registered user (no blind spam to arbitrary addresses); slatepack //! must be a registered user (no blind spam to arbitrary addresses); slatepack
//! sizes are capped; entries expire; and the whole feature is behind //! sizes are capped; entries expire; and the whole feature is behind
//! `FEATURE_GRIN_RELAY` so an operator can disable the mailbox. //! `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; use std::sync::Arc;
+23
View File
@@ -222,6 +222,29 @@ impl Database {
.await?) .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<Option<User>, 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))] #[instrument(skip(self))]
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, AppError> { pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, AppError> {
let sql = format!("SELECT {USER_COLS} FROM users WHERE username = $1"); let sql = format!("SELECT {USER_COLS} FROM users WHERE username = $1");