mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(tips): targeted tips, off until an operator asks for one
A targeted tip is addressed to a Smirk handle rather than to whoever holds a share URL. The v3 port shipped public-only and the schema said so: there was no recipient column anywhere, so create_social_tip rejected is_public=false outright and the two inbox endpoints returned an empty list unconditionally. This is the missing half. The migration adds recipient_user_id plus two CHECKs that keep each kind of tip well formed: a targeted tip must name its recipient, and must carry the ciphertext, because with no URL fragment that ciphertext is the only way to claim it. Without the second one a draft could be created that looks healthy and is permanently unclaimable. Both are satisfied by every existing row, which is public, so nothing is backfilled or rewritten. is_public is derived from the recipient in the INSERT rather than passed alongside it. Two independent sources for "who may claim this" is how a tip ends up addressed to one person and claimable by anyone. Claiming was already guarded by is_public = TRUE, so no targeted tip could be claimed at all; that becomes "public, or the caller is the recipient". The claimable query repeats exactly the conditions the claim call enforces, so the inbox cannot show a Claim button the backend then refuses. It is off by default and separately from FEATURE_TIPS, because it is a distinct surface: an inbox, and a claim authorised by identity rather than by holding a secret. It also needs the migration to have run, so an instance must opt in rather than acquire it by upgrading. The capability is advertised only when tipping is also on, since a targeted tip is still a tip, and recipient resolution checks the flag before anything else: a backend that cannot serve this must refuse, never quietly fall back to creating a public tip from a targeted request, which would hand a tip meant for one person to anyone with the URL. The config fixture moves to Config::test_default so a test of what a flag does can live next to the flag.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
-- Targeted social tips: a tip addressed to a Smirk user rather than to whoever
|
||||
-- holds a share URL.
|
||||
--
|
||||
-- The v3 port shipped public-only and the table says so: there is no recipient
|
||||
-- anywhere, so a targeted tip had no one to be addressed to and the create path
|
||||
-- rejected it outright. This adds the missing column and the two constraints
|
||||
-- that keep each kind of tip well formed.
|
||||
--
|
||||
-- Additive and reversible in practice: every existing row is public, so the new
|
||||
-- column is NULL for all of them and the new CHECK is satisfied by is_public.
|
||||
-- Nothing is dropped, rewritten or backfilled.
|
||||
|
||||
ALTER TABLE social_tips
|
||||
ADD COLUMN IF NOT EXISTS recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- A targeted tip must name its recipient. The mirror of public_tip_has_hash:
|
||||
-- between them, every row carries whichever claim path it actually uses, and a
|
||||
-- row that carries neither cannot be inserted.
|
||||
ALTER TABLE social_tips
|
||||
DROP CONSTRAINT IF EXISTS targeted_tip_has_recipient;
|
||||
ALTER TABLE social_tips
|
||||
ADD CONSTRAINT targeted_tip_has_recipient
|
||||
CHECK (is_public = TRUE OR recipient_user_id IS NOT NULL);
|
||||
|
||||
-- A targeted tip's ciphertext is sealed to the recipient's key and there is no
|
||||
-- URL fragment, so encrypted_key is the only way to claim it. Without this a
|
||||
-- draft could be created that is permanently unclaimable while looking healthy.
|
||||
ALTER TABLE social_tips
|
||||
DROP CONSTRAINT IF EXISTS targeted_tip_has_encrypted_key;
|
||||
ALTER TABLE social_tips
|
||||
ADD CONSTRAINT targeted_tip_has_encrypted_key
|
||||
CHECK (is_public = TRUE OR encrypted_key IS NOT NULL);
|
||||
|
||||
-- The inbox query: a recipient's tips, newest first.
|
||||
CREATE INDEX IF NOT EXISTS idx_social_tips_recipient
|
||||
ON social_tips (recipient_user_id, created_at DESC)
|
||||
WHERE recipient_user_id IS NOT NULL;
|
||||
+9
-2
@@ -756,7 +756,8 @@
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Tips CLAIMABLE by the caller. As with `received`, a public-only instance has\nno targeted-claimable inbox, so this is always empty (served 200, not 404).",
|
||||
"summary": "Tips CLAIMABLE by the caller right now.",
|
||||
"description": "A strict subset of `received`, filtered by exactly the conditions the claim\ncall itself enforces, so the inbox never offers a Claim the backend will then\nrefuse.",
|
||||
"operationId": "get_claimable_social_tips",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -788,7 +789,8 @@
|
||||
"tags": [
|
||||
"tips"
|
||||
],
|
||||
"summary": "Tips RECEIVED by the caller. Public-only instances have no targeted-recipient\ninbox (public tips are claimed via share URL, never delivered to a user), so\nthis is always empty — served (200) rather than 404 so the client's inbox\npoll doesn't error on a targeted-only endpoint this instance doesn't serve.",
|
||||
"summary": "Tips RECEIVED by the caller, newest first.",
|
||||
"description": "Public tips never appear here: they are claimed by holding a share URL and\nhave no addressee to deliver to. An instance with targeted tips off has\nnothing to list and answers 200 with an empty list, so the client's inbox\npoll does not error on a backend that simply does not offer the feature.",
|
||||
"operationId": "get_received_social_tips",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -3324,6 +3326,7 @@
|
||||
"premium_relay",
|
||||
"feed",
|
||||
"tips",
|
||||
"targeted_tips",
|
||||
"xmr_subaddr_provisioning",
|
||||
"utxo_multi_address"
|
||||
],
|
||||
@@ -3360,6 +3363,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Fiat price feed."
|
||||
},
|
||||
"targeted_tips": {
|
||||
"type": "boolean",
|
||||
"description": "Tips addressed to a Smirk handle rather than to a share URL. A client\nmust not offer the targeted option unless this is true, because the\ncreate call refuses it and the send would fail after the user picked a\nrecipient and an amount."
|
||||
},
|
||||
"tips": {
|
||||
"type": "boolean",
|
||||
"description": "Tipping (parked)."
|
||||
|
||||
@@ -74,6 +74,11 @@ pub struct FeatureCapabilities {
|
||||
pub feed: bool,
|
||||
/// Tipping (parked).
|
||||
pub tips: bool,
|
||||
/// Tips addressed to a Smirk handle rather than to a share URL. A client
|
||||
/// must not offer the targeted option unless this is true, because the
|
||||
/// create call refuses it and the send would fail after the user picked a
|
||||
/// recipient and an amount.
|
||||
pub targeted_tips: bool,
|
||||
/// Monero/Wownero subaddress provisioning: this instance registers a batch
|
||||
/// of account-0 subaddress indices with its LWS and serves
|
||||
/// `POST /wallet/lws/provision_subaddrs`. Off ⇒ that route is not mounted
|
||||
@@ -308,6 +313,10 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
premium_relay: premium_advertised(config),
|
||||
feed: feed_advertised(config),
|
||||
tips: tips_advertised(config),
|
||||
// Narrower than `tips` and never wider: a targeted tip is still a
|
||||
// tip, so an instance with tipping off cannot serve one whatever
|
||||
// this flag says.
|
||||
targeted_tips: tips_advertised(config) && config.features.targeted_tips,
|
||||
// Advertised through the SAME predicates the routers gate on, so a
|
||||
// client can never be told a route exists that would 404 (or the
|
||||
// reverse). Both are environment flags, and both are chain-gated:
|
||||
@@ -427,3 +436,40 @@ pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<Capabiliti
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/capabilities", get(capabilities))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
|
||||
/// Targeted tipping is a distinct surface (an inbox, and a claim authorised
|
||||
/// by identity rather than by holding a secret) and it requires a migration
|
||||
/// to have run, so an operator has to ask for it explicitly.
|
||||
#[test]
|
||||
fn targeted_tips_are_off_until_asked_for() {
|
||||
let config = Config::test_default();
|
||||
assert!(!config.features.targeted_tips);
|
||||
}
|
||||
|
||||
/// A targeted tip is still a tip. Advertising it on an instance with tipping
|
||||
/// off would offer a client a route that refuses every request.
|
||||
#[test]
|
||||
fn targeted_tips_are_never_advertised_more_widely_than_tips() {
|
||||
for (tips, targeted) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let mut config = Config::test_default();
|
||||
config.features.tips = tips;
|
||||
config.features.targeted_tips = targeted;
|
||||
let caps = effective_capabilities(&config);
|
||||
assert!(
|
||||
!caps.features.targeted_tips || caps.features.tips,
|
||||
"advertised targeted tips with tips={tips}"
|
||||
);
|
||||
if !targeted {
|
||||
assert!(
|
||||
!caps.features.targeted_tips,
|
||||
"advertised a feature nobody enabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+94
-22
@@ -169,6 +169,50 @@ fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the user a targeted tip is addressed to.
|
||||
///
|
||||
/// Fails closed on the capability first: a backend without the
|
||||
/// `recipient_user_id` migration, or one whose operator has not turned this on,
|
||||
/// must refuse rather than silently create a public tip out of a targeted
|
||||
/// request. That mistake would hand a tip meant for one person to anyone with
|
||||
/// the URL, so the ordering here matters more than the tidiness.
|
||||
///
|
||||
/// Only Smirk handles resolve. This backend serves no third-party platform
|
||||
/// mapping, so a `platform` of anything else has nothing to look up and is
|
||||
/// refused by name rather than resolving to nobody.
|
||||
async fn resolve_tip_recipient(
|
||||
state: &AppState,
|
||||
req: &CreateSocialTipRequest,
|
||||
) -> Result<Uuid, AppError> {
|
||||
if !state.cfg().features.targeted_tips {
|
||||
return Err(AppError::ValidationError(
|
||||
"Targeted tips are not enabled on this instance; set is_public = true.".into(),
|
||||
));
|
||||
}
|
||||
let platform = req.platform.as_deref().unwrap_or("smirk").to_lowercase();
|
||||
if platform != "smirk" {
|
||||
return Err(AppError::ValidationError(format!(
|
||||
"This instance resolves Smirk handles only, not '{platform}'."
|
||||
)));
|
||||
}
|
||||
let username = req
|
||||
.username
|
||||
.as_deref()
|
||||
.map(|u| u.trim().trim_start_matches('@').to_lowercase())
|
||||
.filter(|u| !u.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::ValidationError("username is required for a targeted tip.".into())
|
||||
})?;
|
||||
let user = state
|
||||
.db
|
||||
.get_user_by_username(&username)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::ValidationError(format!("No Smirk user owns the handle '{username}'."))
|
||||
})?;
|
||||
Ok(user.id)
|
||||
}
|
||||
|
||||
/// Whether `asset` is a tip-capable chain enabled on this instance.
|
||||
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
||||
let c = &state.cfg().features.chains;
|
||||
@@ -285,12 +329,14 @@ pub async fn create_social_tip(
|
||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||
ensure_tips_enabled(&state)?;
|
||||
|
||||
// Public-only port: targeted tips are rejected outright.
|
||||
if !req.is_public {
|
||||
return Err(AppError::ValidationError(
|
||||
"Targeted tips are not supported on this instance; set is_public = true.".into(),
|
||||
));
|
||||
}
|
||||
// A targeted tip is addressed to a user; a public one is claimed by whoever
|
||||
// holds the share URL. Resolve the addressee first, because everything below
|
||||
// (which fields are required, which are forbidden) follows from it.
|
||||
let recipient_user_id = if req.is_public {
|
||||
None
|
||||
} else {
|
||||
Some(resolve_tip_recipient(&state, &req).await?)
|
||||
};
|
||||
if req.amount <= 0 {
|
||||
return Err(AppError::ValidationError("amount must be positive.".into()));
|
||||
}
|
||||
@@ -301,21 +347,29 @@ pub async fn create_social_tip(
|
||||
)));
|
||||
}
|
||||
|
||||
// Public tips require the claim-key hash (mirrors the public_tip_has_hash CHECK).
|
||||
let claim_key_hash = req
|
||||
// Public tips require the claim-key hash (mirrors the public_tip_has_hash
|
||||
// CHECK). A targeted tip has no claim key at all: the ciphertext is sealed
|
||||
// to the recipient's own key, so holding the row grants nothing.
|
||||
let claim_key_hash = match recipient_user_id {
|
||||
None => {
|
||||
let h = req
|
||||
.claim_key_hash
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::ValidationError("claim_key_hash is required for a public tip.".into())
|
||||
})?;
|
||||
// A claim-key hash is SHA256(claim key) = exactly 64 hex chars. Validate the
|
||||
// shape so malformed input is a clean 400, not a DB length-overflow 500.
|
||||
if claim_key_hash.len() != 64 || hex::decode(claim_key_hash).is_err() {
|
||||
// SHA256(claim key) = exactly 64 hex chars. Validate the shape so
|
||||
// malformed input is a clean 400, not a DB length-overflow 500.
|
||||
if h.len() != 64 || hex::decode(h).is_err() {
|
||||
return Err(AppError::ValidationError(
|
||||
"claim_key_hash must be a 64-character hex SHA256.".into(),
|
||||
));
|
||||
}
|
||||
Some(h)
|
||||
}
|
||||
Some(_) => None,
|
||||
};
|
||||
|
||||
// encrypted_key: hex, capped at 4096 hex chars.
|
||||
let encrypted_key: Option<Vec<u8>> = match req.encrypted_key.as_deref() {
|
||||
@@ -368,13 +422,14 @@ pub async fn create_social_tip(
|
||||
sender_user_id: user_id,
|
||||
asset: &asset,
|
||||
amount: req.amount,
|
||||
claim_key_hash: Some(claim_key_hash),
|
||||
claim_key_hash,
|
||||
encrypted_key: encrypted_key.as_deref(),
|
||||
tip_address,
|
||||
funding_txid,
|
||||
tip_view_key: req.tip_view_key.as_deref().filter(|s| !s.is_empty()),
|
||||
confirmations_required: confirmations_for_asset(&asset),
|
||||
grin_commitment,
|
||||
recipient_user_id,
|
||||
};
|
||||
|
||||
let tip = if is_draft {
|
||||
@@ -421,10 +476,12 @@ pub async fn get_sent_social_tips(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Tips RECEIVED by the caller. Public-only instances have no targeted-recipient
|
||||
/// inbox (public tips are claimed via share URL, never delivered to a user), so
|
||||
/// this is always empty — served (200) rather than 404 so the client's inbox
|
||||
/// poll doesn't error on a targeted-only endpoint this instance doesn't serve.
|
||||
/// Tips RECEIVED by the caller, newest first.
|
||||
///
|
||||
/// Public tips never appear here: they are claimed by holding a share URL and
|
||||
/// have no addressee to deliver to. An instance with targeted tips off has
|
||||
/// nothing to list and answers 200 with an empty list, so the client's inbox
|
||||
/// poll does not error on a backend that simply does not offer the feature.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
get,
|
||||
@@ -441,13 +498,22 @@ pub async fn get_received_social_tips(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SocialTipsResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||
ensure_tips_enabled(&state)?;
|
||||
Ok(Json(SocialTipsResponse { tips: Vec::new() }))
|
||||
if !state.cfg().features.targeted_tips {
|
||||
return Ok(Json(SocialTipsResponse { tips: Vec::new() }));
|
||||
}
|
||||
let rows = state.db.get_received_social_tips(user_id).await?;
|
||||
Ok(Json(SocialTipsResponse {
|
||||
tips: rows.into_iter().map(to_sent_tip).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Tips CLAIMABLE by the caller. As with `received`, a public-only instance has
|
||||
/// no targeted-claimable inbox, so this is always empty (served 200, not 404).
|
||||
/// Tips CLAIMABLE by the caller right now.
|
||||
///
|
||||
/// A strict subset of `received`, filtered by exactly the conditions the claim
|
||||
/// call itself enforces, so the inbox never offers a Claim the backend will then
|
||||
/// refuse.
|
||||
#[utoipa::path(
|
||||
security(("bearer_auth" = [])),
|
||||
get,
|
||||
@@ -464,9 +530,15 @@ pub async fn get_claimable_social_tips(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SocialTipsResponse>, AppError> {
|
||||
extract_user_id_from_token(&state, &headers).await?;
|
||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||
ensure_tips_enabled(&state)?;
|
||||
Ok(Json(SocialTipsResponse { tips: Vec::new() }))
|
||||
if !state.cfg().features.targeted_tips {
|
||||
return Ok(Json(SocialTipsResponse { tips: Vec::new() }));
|
||||
}
|
||||
let rows = state.db.get_claimable_social_tips(user_id).await?;
|
||||
Ok(Json(SocialTipsResponse {
|
||||
tips: rows.into_iter().map(to_sent_tip).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Public tip metadata for a share-URL holder. UNAUTHENTICATED: the tip id (a
|
||||
|
||||
+27
-6
@@ -307,6 +307,13 @@ pub struct FeatureFlags {
|
||||
pub prices_currency: String,
|
||||
/// Parked feature; off by default.
|
||||
pub tips: bool,
|
||||
/// Tips addressed to a Smirk user rather than to whoever holds a share URL.
|
||||
///
|
||||
/// Off by default and independently of [`Self::tips`]: it is a distinct
|
||||
/// surface (a recipient inbox, and a claim authorised by identity rather
|
||||
/// than by holding a secret), and turning tips on must not turn it on by
|
||||
/// implication. Requires the `recipient_user_id` migration to have run.
|
||||
pub targeted_tips: bool,
|
||||
/// Nostr-native identity (NIP-98 login/link, NIP-05 directory).
|
||||
pub nostr_identity: bool,
|
||||
/// Grin slatepack relay (async store-and-forward mailbox for interactive
|
||||
@@ -861,6 +868,7 @@ impl Config {
|
||||
},
|
||||
prices_currency: env_or("PRICES_CURRENCY", "usd").to_lowercase(),
|
||||
tips: env_bool("FEATURE_TIPS", false),
|
||||
targeted_tips: env_bool("FEATURE_TARGETED_TIPS", false),
|
||||
nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true),
|
||||
grin_relay: env_bool("FEATURE_GRIN_RELAY", true),
|
||||
},
|
||||
@@ -1492,12 +1500,13 @@ impl Config {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Fail-closed validation regression tests. `validate()` must reject weak
|
||||
//! or inconsistent security settings rather than booting.
|
||||
use super::*;
|
||||
|
||||
fn valid() -> Config {
|
||||
impl Config {
|
||||
/// A minimal, valid [`Config`] for tests anywhere in this crate.
|
||||
///
|
||||
/// Lives here rather than in one module's test block so a test of what a
|
||||
/// flag DOES can be written next to the flag, instead of next to the
|
||||
/// fixture that happened to exist first.
|
||||
pub(crate) fn test_default() -> Config {
|
||||
let utxo = || UtxoConfig {
|
||||
network: "mainnet".into(),
|
||||
electrum_primary: None,
|
||||
@@ -1548,6 +1557,7 @@ mod tests {
|
||||
prices_assets: vec!["btc".into(), "xmr".into()],
|
||||
prices_currency: "usd".into(),
|
||||
tips: false,
|
||||
targeted_tips: false,
|
||||
nostr_identity: true,
|
||||
grin_relay: true,
|
||||
},
|
||||
@@ -1667,6 +1677,17 @@ mod tests {
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Fail-closed validation regression tests. `validate()` must reject weak
|
||||
//! or inconsistent security settings rather than booting.
|
||||
use super::*;
|
||||
|
||||
fn valid() -> Config {
|
||||
Config::test_default()
|
||||
}
|
||||
|
||||
/// A fully-wired pay-to-register config (for the require_payment tests).
|
||||
fn valid_payment() -> PaymentConfig {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Social-tips (public) DB access.
|
||||
//! Social-tips DB access.
|
||||
//!
|
||||
//! The `social_tips` row plus its query methods on [`Database`]. PUBLIC tips
|
||||
//! only — no targeted/recipient columns. Every status transition is a single
|
||||
//! The `social_tips` row plus its query methods on [`Database`]. Every status transition is a single
|
||||
//! guarded `UPDATE ... WHERE status IN (...) ... RETURNING`, so concurrent
|
||||
//! claims / sweeps / clawbacks race safely on the DB (the guards are the
|
||||
//! money-safety surface; see each method).
|
||||
@@ -26,9 +25,9 @@ pub(crate) const TIP_COLS: &str = "\
|
||||
funding_amount_observed, funding_amount_verified_at, claimed_at, claimed_by_user_id, \
|
||||
clawed_back_at, sweep_txid, sweep_confirmed_at, sweep_block_height, sweep_block_hash, \
|
||||
sweep_confirmed_dm_sent_at, reorg_notified_at, tip_view_key, lws_registered_at, \
|
||||
lws_deactivated_at, created_at, updated_at, grin_commitment";
|
||||
lws_deactivated_at, created_at, updated_at, grin_commitment, recipient_user_id";
|
||||
|
||||
/// A persisted public social tip. Field names/order match [`TIP_COLS`] (sqlx
|
||||
/// A persisted social tip. Field names/order match [`TIP_COLS`] (sqlx
|
||||
/// `FromRow` maps by name).
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct SocialTipRow {
|
||||
@@ -67,9 +66,11 @@ pub struct SocialTipRow {
|
||||
/// on-chain handle the grin confirmation/sweep workers query by. The same
|
||||
/// value also lands in `tip_address`; prefer this explicit field.
|
||||
pub grin_commitment: Option<String>,
|
||||
/// Targeted tips only: the user this tip is addressed to.
|
||||
pub recipient_user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Values for a fresh public tip. `status` is set by the caller
|
||||
/// Values for a fresh tip. `status` is set by the caller
|
||||
/// (`draft` for the two-phase flow, `pending_confirmation` when funding is
|
||||
/// attached at create time).
|
||||
pub struct NewSocialTip<'a> {
|
||||
@@ -84,6 +85,9 @@ pub struct NewSocialTip<'a> {
|
||||
pub confirmations_required: i32,
|
||||
/// Grin voucher commitment (hex). `None` for non-grin tips.
|
||||
pub grin_commitment: Option<&'a str>,
|
||||
/// Targeted tips only: the user this tip is addressed to. `None` makes it
|
||||
/// public, claimable by whoever holds the share URL.
|
||||
pub recipient_user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// A `social_tips` row cancelled by a GC pass, distilled to the fields the
|
||||
@@ -131,11 +135,14 @@ impl Database {
|
||||
status: TipStatus,
|
||||
) -> Result<SocialTipRow, AppError> {
|
||||
let row = sqlx::query_as::<_, SocialTipRow>(&format!(
|
||||
// is_public is DERIVED from the recipient, never passed separately:
|
||||
// two independent sources for "who may claim this" is how a tip ends
|
||||
// up both addressed to someone and claimable by anyone.
|
||||
"INSERT INTO social_tips \
|
||||
(sender_user_id, asset, amount, is_public, claim_key_hash, encrypted_key, \
|
||||
tip_address, funding_txid, status, confirmations_required, tip_view_key, \
|
||||
grin_commitment) \
|
||||
VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
grin_commitment, recipient_user_id) \
|
||||
VALUES ($1, $2, $3, ($12 IS NULL), $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||||
RETURNING {TIP_COLS}"
|
||||
))
|
||||
.bind(new.sender_user_id)
|
||||
@@ -149,6 +156,7 @@ impl Database {
|
||||
.bind(new.confirmations_required)
|
||||
.bind(new.tip_view_key)
|
||||
.bind(new.grin_commitment)
|
||||
.bind(new.recipient_user_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
Ok(row)
|
||||
@@ -190,6 +198,52 @@ impl Database {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// All tips ADDRESSED to this user, newest first, whatever their state.
|
||||
///
|
||||
/// Scoped to `recipient_user_id`, so a public tip never appears in anyone's
|
||||
/// inbox: a public tip is claimed by holding its URL and has no addressee to
|
||||
/// deliver it to.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_received_social_tips(
|
||||
&self,
|
||||
recipient_user_id: Uuid,
|
||||
) -> Result<Vec<SocialTipRow>, AppError> {
|
||||
let rows = sqlx::query_as::<_, SocialTipRow>(&format!(
|
||||
"SELECT {TIP_COLS} FROM social_tips \
|
||||
WHERE recipient_user_id = $1 ORDER BY created_at DESC"
|
||||
))
|
||||
.bind(recipient_user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Tips this user can claim RIGHT NOW.
|
||||
///
|
||||
/// The predicate is deliberately the same set of conditions
|
||||
/// [`Self::mark_tip_claiming`] enforces, so the inbox cannot offer a Claim
|
||||
/// button that the claim call then refuses: funded to the required depth,
|
||||
/// amount verified against what actually arrived, not already swept.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_claimable_social_tips(
|
||||
&self,
|
||||
recipient_user_id: Uuid,
|
||||
) -> Result<Vec<SocialTipRow>, AppError> {
|
||||
let rows = sqlx::query_as::<_, SocialTipRow>(&format!(
|
||||
"SELECT {TIP_COLS} FROM social_tips \
|
||||
WHERE recipient_user_id = $1 \
|
||||
AND status IN ('pending', 'claiming') \
|
||||
AND funding_confirmations >= confirmations_required \
|
||||
AND funding_amount_verified = TRUE \
|
||||
AND sweep_confirmed_at IS NULL \
|
||||
ORDER BY created_at DESC"
|
||||
))
|
||||
.bind(recipient_user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Cancel a still-unfunded draft. Owner-scoped and `draft`-only, so it can
|
||||
/// never cancel a funded/claimed tip (that path is clawback). Returns the id
|
||||
/// if this call cancelled it; `None` if no such draft (wrong owner, wrong
|
||||
@@ -498,7 +552,7 @@ impl Database {
|
||||
AND funding_confirmations >= confirmations_required \
|
||||
AND funding_amount_verified = TRUE \
|
||||
AND sweep_confirmed_at IS NULL \
|
||||
AND is_public = TRUE \
|
||||
AND (is_public = TRUE OR recipient_user_id = $2) \
|
||||
RETURNING {TIP_COLS}"
|
||||
))
|
||||
.bind(tip_id)
|
||||
|
||||
Reference in New Issue
Block a user