From 1d6d67faa597fbff12e9fd4db8500fc9e55cd86b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 18:40:56 -0400 Subject: [PATCH] feat(tips): grin public tips (voucher model via grin node get_outputs + get_kernel) Grin as a new asset arm in the tips machinery (no grin-lws change, no custodial grin_transactions/grin_outputs port): - GrinClient::get_outputs([commit]) (funding: present+height=confirmed) + get_kernel(excess) (sweep spend-height); reached via state.chains.grin. - create-gating (supported_tip_asset grin, validate 66-hex commitment, confirmations_for_asset grin=10), grin_commitment column (migration). - confirmation worker grin arm: get_outputs present -> confs; amount AUTO-VERIFY with warn (voucher hides amount; not a theft vector; matches legacy). - sweep reconciler grin arm: get_outputs-absent + get_kernel height at depth 10 -> settle; absence-without-kernel -> no settle (recoverable, never missettle); reorg revert only on commitment reappearing; probe error never settles/reverts. - 5 grin tests + pure-helper unit tests; 251 lib + 24 tips green. Amount-verification note: the voucher's bulletproof rewind nonce is random + untransmitted, so it can't be rewound today; a clean non-custodial fast-follow is to have the client send the per-voucher rewind nonce and verify via rangeproof rewind (nonce reveals amount only, not spend authority). --- .../20260710000002_grin_tip_commitment.sql | 12 + src/api/capabilities.rs | 7 +- src/api/tips.rs | 40 +++- src/infra/db/social_tips.rs | 14 +- src/infra/grin/api.rs | 83 ++++++- src/infra/grin/mod.rs | 40 ++++ src/infra/grin/secure.rs | 34 +++ src/models/tip_status.rs | 6 +- src/tips/confirmation.rs | 163 ++++++++++++- src/tips/mod.rs | 15 ++ src/tips/sweep_reconciler.rs | 87 ++++++- tests/tips.rs | 224 +++++++++++++++++- 12 files changed, 695 insertions(+), 30 deletions(-) create mode 100644 migrations/20260710000002_grin_tip_commitment.sql diff --git a/migrations/20260710000002_grin_tip_commitment.sql b/migrations/20260710000002_grin_tip_commitment.sql new file mode 100644 index 0000000..eaa7511 --- /dev/null +++ b/migrations/20260710000002_grin_tip_commitment.sql @@ -0,0 +1,12 @@ +-- Grin public tips use a VOUCHER model, not an address + view-key. The sender +-- builds a single-party voucher output locally and shares its blinding factor +-- (the "spend key" for a Pedersen commitment) in the encrypted claim blob. The +-- on-chain handle for that voucher is the output's Pedersen COMMITMENT (hex, 66 +-- chars) — the grin confirmation + sweep workers query the node by commitment +-- (get_outputs present/absent) rather than by a txid. +-- +-- The commitment also arrives in `tip_address` (the client sends the same value +-- for both), but store it in a dedicated column so the grin worker arms read a +-- clear, purpose-named field. Nullable: only grin tips populate it. Preferred +-- over `tip_address` where both exist. +ALTER TABLE social_tips ADD COLUMN grin_commitment VARCHAR(66); diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index e9e5ff1..d64d885 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -334,14 +334,13 @@ fn feed_advertised(config: &Config) -> bool { } /// Whether public tips are actually serveable: the feature is on, a share-URL -/// base is configured, AND at least one supported tip chain (btc/ltc/xmr/wow) +/// base is configured, AND at least one supported tip chain (btc/ltc/xmr/wow/grin) /// is serviceable. Mirrors the chain/relay presence-downgrade — never advertise -/// tips an instance can't escrow on or build share links for. (Grin is out of -/// the tips port, so it is not a supported tip chain here.) +/// tips an instance can't escrow on or build share links for. fn tips_advertised(config: &Config) -> bool { config.features.tips && config.tip_share_base.is_some() - && ["btc", "ltc", "xmr", "wow"] + && ["btc", "ltc", "xmr", "wow", "grin"] .iter() .any(|asset| chain_serviceable(config, asset)) } diff --git a/src/api/tips.rs b/src/api/tips.rs index 07e2c5b..404cb92 100644 --- a/src/api/tips.rs +++ b/src/api/tips.rs @@ -169,8 +169,7 @@ fn ensure_tips_enabled(state: &AppState) -> Result<(), AppError> { } } -/// Whether `asset` is a tip-capable chain enabled on this instance. Grin is not -/// supported by the tips port yet. +/// Whether `asset` is a tip-capable chain enabled on this instance. fn supported_tip_asset(state: &AppState, asset: &str) -> bool { let c = &state.config.features.chains; match asset { @@ -178,14 +177,16 @@ fn supported_tip_asset(state: &AppState, asset: &str) -> bool { "ltc" => c.ltc, "xmr" => c.xmr, "wow" => c.wow, + "grin" => c.grin, _ => false, } } /// Basic tip-address validation. XMR/WOW reuse the CryptoNote validator; BTC/LTC -/// get a light sanity check (the sender funds and later sweeps THEIR OWN -/// address, so a malformed one merely fails funding verification — it can't -/// misdirect funds to a third party). +/// get a light sanity check; grin's "address" is the voucher's 66-hex Pedersen +/// commitment (the sender funds and later sweeps THEIR OWN output, so a malformed +/// one merely fails funding verification — it can't misdirect funds to a third +/// party). fn validate_tip_address(asset: &str, address: &str) -> Result<(), AppError> { if address.is_empty() || address.len() > 255 { return Err(AppError::ValidationError("tip_address is invalid.".into())); @@ -199,6 +200,16 @@ fn validate_tip_address(asset: &str, address: &str) -> Result<(), AppError> { Err(AppError::ValidationError("tip_address is invalid.".into())) } } + // Grin: a Pedersen commitment is a compressed point, 33 bytes = 66 hex + // chars. Reject anything else so a malformed value is a clean 400 (and + // never overflows the VARCHAR(66) grin_commitment column). + "grin" => { + if address.len() == 66 && address.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(()) + } else { + Err(AppError::ValidationError("tip_address is invalid.".into())) + } + } _ => Err(AppError::ValidationError("unsupported tip asset.".into())), } } @@ -333,6 +344,24 @@ pub async fn create_social_tip( validate_tip_address(&asset, addr)?; } + // Grin tips use a voucher: the on-chain handle is the output's Pedersen + // commitment, which the client sends as BOTH tip_address and grin_commitment. + // Prefer the explicit field, fall back to tip_address, and validate it as a + // 66-hex commitment so a malformed value is a clean 400 (not a DB overflow). + let grin_commitment = if asset == "grin" { + let commit = req + .grin_commitment + .as_deref() + .filter(|s| !s.is_empty()) + .or(tip_address); + if let Some(commit) = commit { + validate_tip_address("grin", commit)?; + } + commit + } else { + None + }; + let new = NewSocialTip { sender_user_id: user_id, asset: &asset, @@ -343,6 +372,7 @@ pub async fn create_social_tip( funding_txid, tip_view_key: req.tip_view_key.as_deref().filter(|s| !s.is_empty()), confirmations_required: confirmations_for_asset(&asset), + grin_commitment, }; let tip = if is_draft { diff --git a/src/infra/db/social_tips.rs b/src/infra/db/social_tips.rs index df48b24..f409195 100644 --- a/src/infra/db/social_tips.rs +++ b/src/infra/db/social_tips.rs @@ -26,7 +26,7 @@ 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"; + lws_deactivated_at, created_at, updated_at, grin_commitment"; /// A persisted public social tip. Field names/order match [`TIP_COLS`] (sqlx /// `FromRow` maps by name). @@ -63,6 +63,10 @@ pub struct SocialTipRow { pub lws_deactivated_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, + /// Grin voucher commitment (hex, 66 chars). Set only for grin tips; the + /// 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, } /// Values for a fresh public tip. `status` is set by the caller @@ -78,6 +82,8 @@ pub struct NewSocialTip<'a> { pub funding_txid: Option<&'a str>, pub tip_view_key: Option<&'a str>, pub confirmations_required: i32, + /// Grin voucher commitment (hex). `None` for non-grin tips. + pub grin_commitment: Option<&'a str>, } /// A `social_tips` row cancelled by a GC pass, distilled to the fields the @@ -127,8 +133,9 @@ impl Database { let row = sqlx::query_as::<_, SocialTipRow>(&format!( "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) \ - VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9, $10) \ + 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) \ RETURNING {TIP_COLS}" )) .bind(new.sender_user_id) @@ -141,6 +148,7 @@ impl Database { .bind(status.as_str()) .bind(new.confirmations_required) .bind(new.tip_view_key) + .bind(new.grin_commitment) .fetch_one(self.pool()) .await?; Ok(row) diff --git a/src/infra/grin/api.rs b/src/infra/grin/api.rs index c029105..3a8de39 100644 --- a/src/infra/grin/api.rs +++ b/src/infra/grin/api.rs @@ -7,7 +7,7 @@ use tracing::instrument; -use super::{GrinClient, GrinStatus, ViewWallet}; +use super::{GrinClient, GrinKernelInfo, GrinOutputInfo, GrinStatus, ViewWallet}; use crate::error::AppError; impl GrinClient { @@ -86,6 +86,71 @@ impl GrinClient { Ok(self.get_status().await?.tip.height) } + /// Basic-auth for the node Foreign API (user "grin"), or `None` when no + /// secret is configured. Mirrors [`broadcast`]'s auth handling. + fn foreign_auth(&self) -> Option<(&str, &str)> { + if self.node_foreign_api_secret.is_empty() { + None + } else { + Some(("grin", self.node_foreign_api_secret.expose())) + } + } + + /// Query the node's output set (UTXO + optional history) for `commits` via the + /// Foreign API `get_outputs`. A commitment PRESENT in the result (with a block + /// height) is confirmed on-chain; ABSENT means it is not in the queried set + /// (unconfirmed, or — for a previously-seen voucher — spent). Used to detect + /// grin tip funding confirmation and voucher sweeps. + /// + /// `get_outputs` params: `[commits, start_height, end_height, include_proof, + /// include_merkle_proof]`. The node wraps results in `{"Ok": [...]}`; the + /// transport unwraps that. An empty `commits` short-circuits to `[]`. + #[instrument(skip(self, commits), fields(num_commits = commits.len()))] + pub async fn get_outputs(&self, commits: &[String]) -> Result, AppError> { + if commits.is_empty() { + return Ok(Vec::new()); + } + self.node_rpc( + &self.node_foreign_api_url, + self.foreign_auth(), + "get_outputs", + ( + commits, + Option::::None, + Option::::None, + false, + false, + ), + ) + .await + } + + /// Look up the block height a transaction kernel was mined at via the Foreign + /// API `get_kernel`, keyed by the kernel EXCESS (hex). Returns `Some(height)` + /// when the kernel is on-chain, `None` when it is not yet mined (the node + /// returns `{"Ok": null}` or `{"Err":"NotFound"}` — both map to `None`). A + /// transport / JSON-RPC error stays `Err`. + /// + /// This is a HEIGHT lookup only — the definitive spend fact for a voucher is + /// the commitment's absence from `get_outputs`; `get_kernel` merely dates that + /// spend so the reconciler can gate on confirmation depth. `get_kernel` + /// params: `[excess, min_height, max_height]`. + #[instrument(skip(self, excess))] + pub async fn get_kernel(&self, excess: &str) -> Result, AppError> { + let kernel: Option> = self + .node_rpc_optional( + &self.node_foreign_api_url, + self.foreign_auth(), + "get_kernel", + (excess, Option::::None, Option::::None), + ) + .await?; + // Outer Option = Ok vs inner-Err(NotFound); inner Option = the API's + // `Option` (`{"Ok": null}` when absent). Flatten both to + // a single "mined height or not". + Ok(kernel.flatten().map(|k| k.height)) + } + /// Broadcast a finalized transaction via the node Foreign API /// (`push_transaction`). The wallet builds + signs locally; the backend only /// relays. `tx` is the finalized transaction object. @@ -113,7 +178,7 @@ impl GrinClient { mod tests { use super::*; use crate::config::GrinConfig; - use crate::infra::grin::ViewWalletOutputResult; + use crate::infra::grin::{GrinOutputInfo, ViewWalletOutputResult}; fn cfg() -> GrinConfig { GrinConfig { @@ -149,4 +214,18 @@ mod tests { assert_eq!(out(0).confirmations(110), 0); // unconfirmed / no height assert_eq!(out(120).confirmations(110), 0); // beyond tip -> not a bogus count } + + #[test] + fn output_confirmations_handles_missing_and_future_height() { + let out = |block_height| GrinOutputInfo { + commit: "c".into(), + block_height, + mmr_index: 0, + }; + assert_eq!(out(Some(100)).confirmations(110), 11); // 110 - 100 + 1 + assert_eq!(out(Some(110)).confirmations(110), 1); // tip block + assert_eq!(out(None).confirmations(110), 0); // no block height yet + assert_eq!(out(Some(0)).confirmations(110), 0); // height 0 -> unconfirmed + assert_eq!(out(Some(120)).confirmations(110), 0); // beyond tip -> not bogus + } } diff --git a/src/infra/grin/mod.rs b/src/infra/grin/mod.rs index 97f223d..d374646 100644 --- a/src/infra/grin/mod.rs +++ b/src/infra/grin/mod.rs @@ -134,3 +134,43 @@ pub struct GrinStatus { pub struct GrinTip { pub height: u64, } + +/// One output returned by the node Foreign API `get_outputs`. The commitment is +/// the on-chain handle for a grin voucher; its presence in the UTXO set (with a +/// block height) is how tips detect funding confirmation, and its ABSENCE is how +/// they detect the voucher was swept. `block_height` is `Option` because the +/// node's `OutputPrintable.block_height` is nullable. +#[derive(Debug, Clone, Deserialize)] +pub struct GrinOutputInfo { + /// The Pedersen commitment (hex). + pub commit: String, + /// Block height the output was included at (`None` if not yet in a block). + #[serde(rename = "block_height", default)] + pub block_height: Option, + /// Output MMR index. + #[serde(default)] + pub mmr_index: u64, +} + +impl GrinOutputInfo { + /// Confirmations at `tip_height`; `0` if the output has no block height or + /// claims a block beyond the tip (a lagging/inconsistent node) rather than a + /// bogus count. Mirrors [`ViewWalletOutputResult::confirmations`]. + pub fn confirmations(&self, tip_height: u64) -> u64 { + match self.block_height { + Some(h) if h > 0 && h <= tip_height => { + tip_height.saturating_sub(h).saturating_add(1) + } + _ => 0, + } + } +} + +/// A located transaction kernel returned by the node Foreign API `get_kernel`. +/// Only the confirmed `height` is read (the spend's block height); the kernel +/// body + `mmr_index` are ignored. Extra fields are tolerated by serde. +#[derive(Debug, Clone, Deserialize)] +pub struct GrinKernelInfo { + /// Block height the kernel (and thus its spending tx) was mined at. + pub height: u64, +} diff --git a/src/infra/grin/secure.rs b/src/infra/grin/secure.rs index 29d3d44..2ca55f9 100644 --- a/src/infra/grin/secure.rs +++ b/src/infra/grin/secure.rs @@ -221,6 +221,40 @@ impl GrinClient { envelope_into_result(env, "grin node") } + /// Like [`node_rpc`], but treats Grin's inner `{"Err": _}` result as a + /// legitimate "not found" (`Ok(None)`) rather than an error. Grin's Foreign + /// API `get_kernel` returns `{"result":{"Err":"NotFound"}}` for an unmined / + /// unknown excess; the caller must distinguish that (kernel not mined yet) + /// from a transport/JSON-RPC error (which stays `Err`). A `{"Ok": _}` result + /// maps to `Ok(Some(_))`; a JSON-RPC `error` envelope or a malformed body + /// stays `Err`. + pub(super) async fn node_rpc_optional( + &self, + url: &str, + auth: Option<(&str, &str)>, + method: &str, + params: T, + ) -> Result, AppError> + where + T: Serialize, + R: DeserializeOwned, + { + let bytes = self + .post_capped(url, auth, &JsonRpcRequest::new(method, params)) + .await?; + let env: RpcEnvelope = serde_json::from_slice(&bytes) + .map_err(|_| AppError::NodeError("grin node: invalid response".into()))?; + if let Some(e) = env.error { + return Err(AppError::NodeError(format!("grin node error {}", e.code))); + } + match env.result { + Some(GrinOk::Ok(r)) => Ok(Some(r)), + // `{"Err": "NotFound"}` (or any inner Err): not an outage, just absent. + Some(GrinOk::Err(_)) => Ok(None), + None => Err(AppError::NodeError("grin node: empty result".into())), + } + } + /// POST a JSON body (optionally basic-auth'd) and return the size-capped /// response bytes. The body is never logged or echoed into an error. async fn post_capped( diff --git a/src/models/tip_status.rs b/src/models/tip_status.rs index dce5fe3..7b4717d 100644 --- a/src/models/tip_status.rs +++ b/src/models/tip_status.rs @@ -89,11 +89,11 @@ impl fmt::Display for TipStatus { } /// Confirmations required before an asset's funding tx is spendable/claimable. -/// XMR 10, WOW 4, BTC/LTC 0 (immediately spendable). Grin is not supported in -/// the public-tips port yet. +/// XMR 10, GRIN 10, WOW 4, BTC/LTC 0 (immediately spendable). pub const fn confirmations_for_asset(asset: &str) -> i32 { match asset.as_bytes() { b"xmr" => 10, + b"grin" => 10, b"wow" => 4, _ => 0, } @@ -160,6 +160,6 @@ mod tests { assert_eq!(confirmations_for_asset("wow"), 4); assert_eq!(confirmations_for_asset("btc"), 0); assert_eq!(confirmations_for_asset("ltc"), 0); - assert_eq!(confirmations_for_asset("grin"), 0); + assert_eq!(confirmations_for_asset("grin"), 10); } } diff --git a/src/tips/confirmation.rs b/src/tips/confirmation.rs index 03a7be6..2f554df 100644 --- a/src/tips/confirmation.rs +++ b/src/tips/confirmation.rs @@ -1,24 +1,31 @@ //! Funding confirmation + amount verification for public social tips. //! -//! Ported (public subset) from the legacy `core/tip_confirmation.rs`. Grin and -//! all DM/announcement side-effects are dropped: public tips deliver by share -//! URL, so the "fire the recipient a notification" branches become no-ops here. +//! Ported (public subset) from the legacy `core/tip_confirmation.rs`. All +//! DM/announcement side-effects are dropped: public tips deliver by share URL, +//! so the "fire the recipient a notification" branches become no-ops here. //! //! Confirmation thresholds (see [`crate::models::tip_status::confirmations_for_asset`]): -//! XMR 10, WOW 4, BTC/LTC 0. BTC/LTC skip the confirmation-count pass entirely -//! and go straight to amount verification. +//! XMR 10, GRIN 10, WOW 4, BTC/LTC 0. BTC/LTC skip the confirmation-count pass +//! entirely and go straight to amount verification. +//! +//! Grin is a VOUCHER asset, not address+view-key: funding "confirmation" is the +//! output commitment appearing in the node's UTXO set (`get_outputs`), and the +//! amount is NOT on-chain verifiable (the commitment hides it), so once the +//! commitment confirms the declared amount is AUTO-VERIFIED (with a `warn!`). use std::sync::Arc; use tracing::{debug, info, instrument, warn}; +use super::grin_commitment; use crate::error::AppError; use crate::infra::db::SocialTipRow; use crate::infra::lws::AddressTxsResponse; use crate::AppState; /// CryptoNote assets that require an on-chain confirmation count before the -/// amount pass. BTC/LTC are deliberately absent (`confirmations_required = 0`). +/// amount pass. BTC/LTC are deliberately absent (`confirmations_required = 0`); +/// grin is handled by [`check_grin_confirmations`] (voucher, not address+view). const CONFIRMATION_ASSETS: [&str; 2] = ["xmr", "wow"]; /// Run one full funding pass: confirmation counting for XMR/WOW, then the @@ -33,6 +40,11 @@ pub async fn run_tip_confirmation_cycle(state: Arc) { } } + // Grin's confirmation count is a commitment lookup, not an address-txs poll. + if let Err(e) = check_grin_confirmations(&state).await { + warn!(error = %e, asset = "grin", "grin tip confirmation-count pass failed"); + } + if let Err(e) = process_pending_verifications(&state).await { warn!(error = %e, "tip funding-amount verification pass failed"); } @@ -93,6 +105,62 @@ async fn check_asset_confirmations(state: &AppState, asset: &str) -> Result<(), Ok(()) } +/// Grin funding-confirmation pass. Grin is a voucher asset: the confirmation +/// signal is the output COMMITMENT appearing in the node's UTXO set, so we probe +/// `get_outputs([commitment])` per pending tip rather than a funding txid. Confs +/// = `tip_height − block_height + 1` when present, `0` when absent (unconfirmed, +/// or the voucher was already swept — the sweep reconciler owns that case). +/// +/// Same money-safety contract as [`check_asset_confirmations`]: +/// `update_tip_confirmations` writes only the counter + timestamps (never +/// status), and any node error leaves the row untouched for the next poll. +#[instrument(skip(state))] +async fn check_grin_confirmations(state: &AppState) -> Result<(), AppError> { + let Some(grin) = state.chains.grin.as_ref() else { + return Ok(()); + }; + + let tips = state.db.get_tips_pending_confirmation("grin").await?; + if tips.is_empty() { + debug!("no grin tips awaiting confirmation"); + return Ok(()); + } + info!(count = tips.len(), "checking grin tip funding confirmations"); + + // One tip-height read for the whole batch. A node error here aborts the pass + // (logged/swallowed by the caller) leaving every row untouched — never a + // partial mutation. + let tip_height = grin.get_height().await?; + + for tip in &tips { + let Some(commit) = grin_commitment(tip) else { + warn!(tip_id = %tip.id, "grin confirmation: no commitment — skipping"); + continue; + }; + match grin.get_outputs(std::slice::from_ref(&commit)).await { + Ok(outputs) => { + // Present (with a block height) → confs; absent → 0 (and the + // timestamp stamp still lands, so we don't re-poll it hot). + let confs = outputs + .iter() + .find(|o| o.commit == commit) + .map(|o| o.confirmations(tip_height)) + .unwrap_or(0); + let confs_i32 = confs.min(i32::MAX as u64) as i32; + if let Err(e) = state.db.update_tip_confirmations(tip.id, confs_i32).await { + warn!(tip_id = %tip.id, error = %e, "failed to update grin tip confirmations"); + } + } + Err(e) => { + // Node error: leave the row untouched for the next poll. NEVER + // mutate status (or the counter) here. + warn!(tip_id = %tip.id, error = %e, "failed to fetch grin get_outputs"); + } + } + } + Ok(()) +} + /// Result of checking whether the on-chain receipt at a tip's address covers the /// sender-declared `amount`. The verifier acts on this; only `Verified` / /// `Short` mutate status. @@ -166,6 +234,22 @@ pub(crate) fn classify_funding_amount( } } +/// Grin amount verification from the voucher commitment's presence in the UTXO +/// set. Grin amounts are NOT on-chain verifiable (a Pedersen commitment hides the +/// value), so once the commitment is confirmed the sender-declared `declared` +/// amount is AUTO-VERIFIED (mirrors the legacy grin path). Present → +/// `Verified{observed: declared}`; absent → `Unfunded` (stay pending for the next +/// poll). Grin NEVER returns `Short`/`Drained` — it can never mass-mismatch. Pure +/// (no I/O), so unit-testable like [`classify_funding_amount`]. +pub(crate) fn grin_amount_from_presence(present: bool, declared: i64) -> FundingVerification { + if present { + let observed = if declared < 0 { 0 } else { declared as u64 }; + FundingVerification::Verified { observed } + } else { + FundingVerification::Unfunded + } +} + /// Verify that a tip's on-chain funding covers `amount`. /// /// XMR/WOW: `get_address_txs(tip_address, tip_view_key)` → `classify_funding_amount`. @@ -281,10 +365,37 @@ async fn verify_funding_amount(state: &AppState, tip: &SocialTipRow) -> FundingV FundingVerification::Short { observed: net } } } + "grin" => { + let Some(grin) = state.chains.grin.as_ref() else { + warn!(tip_id = %tip.id, "verify: grin client disabled — staying in pending_confirmation"); + return FundingVerification::LwsError; + }; + let Some(commit) = grin_commitment(tip) else { + warn!(tip_id = %tip.id, "verify: grin commitment missing — treating as LwsError"); + return FundingVerification::LwsError; + }; + match grin.get_outputs(std::slice::from_ref(&commit)).await { + Ok(outputs) => { + let present = outputs.iter().any(|o| o.commit == commit); + if present { + // The commitment hides the value, so we CANNOT cross-check + // the amount on-chain — auto-verify the declared amount and + // flag it loudly (mirrors legacy). Never a mass-mismatch. + warn!(tip_id = %tip.id, declared = tip.amount, "verify: grin amount not cross-checked on-chain — auto-verifying declared amount"); + } + grin_amount_from_presence(present, tip.amount) + } + Err(e) => { + warn!(tip_id = %tip.id, error = %e, "verify: grin get_outputs failed — staying in pending_confirmation"); + FundingVerification::LwsError + } + } + } other => { - // Only btc/ltc/xmr/wow can ever create a tip, so this is unreachable - // in practice. Do NOT auto-verify an unknown asset (that would be a - // money-safety hole) — treat it as an error so the row stays put. + // Only btc/ltc/xmr/wow/grin can ever create a tip, so this is + // unreachable in practice. Do NOT auto-verify an unknown asset (that + // would be a money-safety hole) — treat it as an error so the row + // stays put. warn!(tip_id = %tip.id, asset = other, "verify: unsupported asset — staying in pending_confirmation"); FundingVerification::LwsError } @@ -464,4 +575,38 @@ mod tests { fn lws_error_distinct_from_unfunded() { assert_ne!(FundingVerification::LwsError, FundingVerification::Unfunded); } + + // ---- grin_amount_from_presence (voucher auto-verify) ------------------- + + #[test] + fn grin_present_auto_verifies_declared_amount() { + // The commitment hides the value; a confirmed commitment auto-verifies + // the declared amount (never Short/Drained). + assert_eq!( + grin_amount_from_presence(true, 500), + FundingVerification::Verified { observed: 500 } + ); + assert_eq!( + grin_amount_from_presence(true, 1), + FundingVerification::Verified { observed: 1 } + ); + } + + #[test] + fn grin_absent_is_unfunded() { + assert_eq!( + grin_amount_from_presence(false, 500), + FundingVerification::Unfunded + ); + } + + #[test] + fn grin_negative_declared_saturates_to_zero() { + // Defensive: create-tip rejects amount <= 0, but a negative never + // underflows the u64 observed. + assert_eq!( + grin_amount_from_presence(true, -1), + FundingVerification::Verified { observed: 0 } + ); + } } diff --git a/src/tips/mod.rs b/src/tips/mod.rs index 3d15d5f..10d31fc 100644 --- a/src/tips/mod.rs +++ b/src/tips/mod.rs @@ -22,3 +22,18 @@ pub mod sweep_reconciler; pub use confirmation::run_tip_confirmation_cycle; pub use gc::{run_tip_draft_gc_cycle, run_tip_lifecycle_gc_cycle}; pub use sweep_reconciler::{run_sweep_reconcile_cycle, run_sweep_reorg_cycle}; + +use crate::infra::db::SocialTipRow; + +/// The Grin voucher commitment (hex) for a tip: the explicit `grin_commitment` +/// column, falling back to `tip_address` (the client sends the commitment as +/// both). `None` (empty/absent) means the grin worker arms can't act on the row. +/// Trimmed; an empty value is treated as absent. +pub(crate) fn grin_commitment(tip: &SocialTipRow) -> Option { + tip.grin_commitment + .as_deref() + .or(tip.tip_address.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} diff --git a/src/tips/sweep_reconciler.rs b/src/tips/sweep_reconciler.rs index a157b8b..9c6b5a2 100644 --- a/src/tips/sweep_reconciler.rs +++ b/src/tips/sweep_reconciler.rs @@ -17,7 +17,14 @@ //! winner ([`pick_winner`]). //! //! ## Confirmation thresholds (mirror the funding thresholds) -//! BTC/LTC 1, WOW 4, XMR 10. Grin is OUT of this port. +//! BTC/LTC 1, WOW 4, XMR 10, GRIN 10. +//! +//! ## Grin (voucher asset) source of truth +//! Grin has no address to scan: the "source of truth" is the voucher output +//! COMMITMENT. Present in `get_outputs` = still unspent (not claimed). Absent = +//! swept; `get_kernel(sweep_txid /* = kernel excess */)` dates the spend so the +//! depth gate + reorg-revert reuse the same machinery. A reorg that un-spends +//! the commitment makes it present again → not confirmed → revert. //! //! ## Money-safety invariants //! - A probe ERROR (Electrum/LWS unreachable, hostile amount) NEVER settles or @@ -36,6 +43,7 @@ use std::sync::Arc; use tracing::{debug, info, instrument, warn}; +use super::grin_commitment; use crate::error::AppError; use crate::infra::db::SocialTipRow; use crate::infra::electrum::VerboseTransaction; @@ -68,12 +76,13 @@ pub struct ConfirmedSpend { /// Per-asset sweep confirmation threshold (confirmations the winning spend needs /// before we settle to `claimed`). Mirrors the funding-side thresholds. Unknown /// assets default to the most conservative (10) so a misconfigured asset never -/// settles early. Grin is not supported by this port. +/// settles early. pub(crate) fn sweep_confirm_threshold(asset: &str) -> u64 { match asset.to_lowercase().as_str() { "btc" | "ltc" => 1, "wow" => 4, "xmr" => 10, + "grin" => 10, _ => 10, } } @@ -301,8 +310,9 @@ async fn probe_confirmed_spends( match asset.as_str() { "btc" | "ltc" => probe_electrum_spends(state, tip, &asset).await, "xmr" | "wow" => probe_lws_spends(state, tip, &asset).await, + "grin" => probe_grin_spends(state, tip).await, other => { - // Grin is out of this port; only btc/ltc/xmr/wow ever create a tip. + // Only btc/ltc/xmr/wow/grin ever create a tip. warn!(tip_id = %tip.id, asset = %other, "sweep probe: unsupported asset"); Err(AppError::ValidationError(format!( "sweep probe: unsupported asset {other}" @@ -311,6 +321,71 @@ async fn probe_confirmed_spends( } } +/// Grin (voucher) probe. The source of truth is the output COMMITMENT: +/// - PRESENT in `get_outputs` → still unspent (the claimer hasn't swept, or the +/// sweep isn't mined): no confirmed spend yet. +/// - ABSENT → the voucher left the UTXO set (swept). Date the spend with +/// `get_kernel(sweep_txid /* = kernel excess */)`: `Some(h)` yields a single +/// `ConfirmedSpend`; `None` (kernel not mined yet) yields no spend. +/// +/// This flows through the unchanged `pick_winner` → depth-10 gate → +/// `confirm_sweep_onchain`. In the REORG cycle it is symmetric: a reorg that +/// un-spends the commitment makes `get_outputs` present again → no +/// `ConfirmedSpend` → the recorded txid vanishes from confirmed history → revert. +/// +/// Money-safety: any node error is `Err` (the caller skips — never settles / +/// reverts). Absence WITHOUT a kernel height (no recorded excess, or the kernel +/// isn't mined) is treated as "no confirmed spend" — a settle needs the +/// affirmative kernel height, so a bogus excess can never fake a settlement; the +/// worst case is a stuck-claiming row (recoverable, surfaced by +/// `log_stuck_claiming`), not fund loss. +async fn probe_grin_spends( + state: &AppState, + tip: &SocialTipRow, +) -> Result<(Vec, i64), AppError> { + let grin = state + .chains + .grin + .as_ref() + .ok_or_else(|| AppError::NodeError("grin client disabled".into()))?; + + let commit = grin_commitment(tip) + .ok_or_else(|| AppError::ValidationError("grin commitment missing for sweep probe".into()))?; + + let tip_height = grin.get_height().await? as i64; + + // Still in the UTXO set → not swept yet. No confirmed spend. + let outputs = grin.get_outputs(std::slice::from_ref(&commit)).await?; + if outputs.iter().any(|o| o.commit == commit) { + return Ok((Vec::new(), tip_height)); + } + + // Absent → swept. We need the sweep kernel excess to date the spend. + let Some(excess) = tip.sweep_txid.as_deref().filter(|s| !s.is_empty()) else { + // No recorded excess: we can't affirmatively date the spend, so we do + // NOT settle. (Stuck-claiming is recoverable; missettling is not.) + debug!(tip_id = %tip.id, "grin voucher absent but no recorded sweep excess — no confirmed spend"); + return Ok((Vec::new(), tip_height)); + }; + + match grin.get_kernel(excess).await? { + Some(height) => Ok(( + vec![ConfirmedSpend { + txid: excess.to_string(), + block_height: height as i64, + block_hash: None, + }], + tip_height, + )), + // Kernel not mined yet: absence of the commitment alone isn't a + // depth-gateable spend, so treat as no confirmed spend (retry). + None => { + debug!(tip_id = %tip.id, "grin voucher absent, sweep kernel not yet mined — no confirmed spend"); + Ok((Vec::new(), tip_height)) + } + } +} + /// BTC/LTC probe via Electrum. For each confirmed history entry (`height > 0`) /// fetch the verbose tx and treat it as a SPEND iff the address received nothing /// in it. `block_hash` stays `None` (no merkle fetch); reorg detection keys on @@ -587,6 +662,12 @@ mod tests { assert_eq!(sweep_confirm_threshold("xmr"), 10); } + #[test] + fn threshold_grin_is_ten() { + assert_eq!(sweep_confirm_threshold("grin"), 10); + assert_eq!(sweep_confirm_threshold("GRIN"), 10); + } + #[test] fn threshold_unknown_defaults_conservative() { assert_eq!(sweep_confirm_threshold("doge"), 10); diff --git a/tests/tips.rs b/tests/tips.rs index 35f55ae..cdcef6d 100644 --- a/tests/tips.rs +++ b/tests/tips.rs @@ -110,13 +110,15 @@ async fn db_create_draft_roundtrips_all_columns() { funding_txid: None, tip_view_key: None, confirmations_required: 0, + grin_commitment: None, }; - // Exercises the full 31-column FromRow decode on RETURNING. + // Exercises the full 32-column FromRow decode on RETURNING. let row = app.state.db.create_draft_social_tip(new).await.expect("create draft"); assert_eq!(row.status, "draft"); assert_eq!(row.amount, 100_000); assert!(row.is_public); assert_eq!(row.encrypted_key.as_deref(), Some(&[0xcd, 0xcd][..])); + assert_eq!(row.grin_commitment, None); } #[tokio::test] @@ -193,6 +195,7 @@ async fn make_draft( funding_txid: None, tip_view_key: view_key, confirmations_required: conf, + grin_commitment: None, }; app.state .db @@ -800,3 +803,222 @@ async fn log_stuck_claiming_returns_backdated_and_mutates_nothing() { assert_eq!(row.status, "claiming", "log_stuck_claiming must not mutate status"); assert!(row.sweep_confirmed_at.is_none(), "log_stuck_claiming must not settle the sweep"); } + +// ── Grin (voucher asset) tests ──────────────────────────────────────────────── +// +// Grin tips carry no address+view-key: the client funds a voucher OUTPUT and +// sends its Pedersen COMMITMENT (66 hex) as both `tip_address` and +// `grin_commitment`. These cover the create-time surface (gating, validation, +// the grin_commitment column/migration, the 10-conf threshold). The +// get_outputs/get_kernel-backed worker paths need a live grin node and are +// documented manual steps (see the module docs in src/tips/{confirmation, +// sweep_reconciler}.rs). + +/// Boot the app with tips + the grin chain flag on (+ a share base). The grin +/// client is built against the empty test grin config; no worker runs in-harness, +/// so the create-time surface is exercised without a live node. +async fn grin_tips_app() -> Option { + common::try_app_with(|c| { + c.features.tips = true; + c.features.chains.grin = true; + c.tip_share_base = Some("https://tips.example".into()); + }) + .await +} + +/// A valid Grin voucher commitment shape: 66 hex chars (33-byte compressed point). +fn grin_commit() -> String { + format!("09{}", "a1".repeat(32)) +} + +#[tokio::test] +async fn grin_public_tip_create_returns_draft_and_share_url() { + let Some(app) = grin_tips_app().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + let (_uid, token, _refresh) = app.mint_session().await; + let commit = grin_commit(); + + // No funding_txid -> the backend creates status='draft' (voucher funds later). + let body = json!({ + "asset": "grin", + "amount": 500, + "is_public": true, + "claim_key_hash": CLAIM_HASH, + "encrypted_key": ENC_KEY_HEX, + "tip_address": commit, + "grin_commitment": commit, + }); + let (status, resp) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) + .await; + assert_eq!(status, StatusCode::OK, "create grin: {resp}"); + assert_eq!(resp["status"], "draft"); + let tip_id = resp["tip_id"].as_str().expect("tip_id").to_string(); + assert_eq!(resp["share_url"], format!("https://tips.example/{tip_id}")); + + // Migration applied: the grin_commitment column round-trips, tip_address + // mirrors it, and the grin funding threshold is 10. + let uuid = tip_id.parse::().unwrap(); + let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row"); + assert_eq!(row.asset, "grin"); + assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str())); + assert_eq!(row.tip_address.as_deref(), Some(commit.as_str())); + assert_eq!(row.confirmations_required, 10); +} + +#[tokio::test] +async fn grin_create_falls_back_to_tip_address_for_commitment() { + let Some(app) = grin_tips_app().await else { return }; + let (_uid, token, _refresh) = app.mint_session().await; + let commit = grin_commit(); + + // Omit grin_commitment entirely -> the backend stores tip_address as the + // commitment (the client sends the same value for both). + let body = json!({ + "asset": "grin", + "amount": 500, + "is_public": true, + "claim_key_hash": CLAIM_HASH, + "tip_address": commit, + }); + let (status, resp) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) + .await; + assert_eq!(status, StatusCode::OK, "create grin (no commit field): {resp}"); + let uuid = resp["tip_id"].as_str().unwrap().parse::().unwrap(); + let row = app.state.db.get_social_tip(uuid).await.expect("get").expect("row"); + assert_eq!(row.grin_commitment.as_deref(), Some(commit.as_str())); +} + +#[tokio::test] +async fn grin_create_rejects_junk_commitment() { + let Some(app) = grin_tips_app().await else { return }; + let (_uid, token, _refresh) = app.mint_session().await; + + // tip_address that isn't a 66-hex commitment -> clean 400. + for bad in [ + "deadbeef".to_string(), // too short + "z".repeat(66), // right length, not hex + format!("09{}", "a1".repeat(33)), // 68 chars, too long + ] { + let body = json!({ + "asset": "grin", + "amount": 500, + "is_public": true, + "claim_key_hash": CLAIM_HASH, + "tip_address": bad, + }); + let (status, resp) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "junk grin address must 400: {resp}"); + } + + // A valid tip_address but a junk explicit grin_commitment is also rejected + // (the explicit field is preferred, so it must validate too). + let body = json!({ + "asset": "grin", + "amount": 500, + "is_public": true, + "claim_key_hash": CLAIM_HASH, + "tip_address": grin_commit(), + "grin_commitment": "z".repeat(66), + }); + let (status, resp) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "junk grin_commitment must 400: {resp}"); +} + +#[tokio::test] +async fn grin_create_rejected_when_chain_disabled() { + // supported_tip_asset gates on the feature flag: grin OFF -> 400 even though + // the wire is otherwise valid. + let Some(app) = common::try_app_with(|c| { + c.features.tips = true; + c.features.chains.grin = false; + c.tip_share_base = Some("https://tips.example".into()); + }) + .await else { + return; + }; + let (_uid, token, _refresh) = app.mint_session().await; + let commit = grin_commit(); + let body = json!({ + "asset": "grin", + "amount": 500, + "is_public": true, + "claim_key_hash": CLAIM_HASH, + "tip_address": commit, + "grin_commitment": commit, + }); + let (status, resp) = app + .request("POST", "/api/v1/tips/social", Some(&token), Some(body)) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "grin disabled must 400: {resp}"); +} + +/// The grin voucher flows through the SAME draft -> attach-funding -> claiming +/// lifecycle as the other assets: attach a client bookkeeping id, mark it +/// funded+verified (as the workers would), claim it, and record the sweep's +/// kernel excess as sweep_txid. Pure DB-level (no node), asserting the columns +/// the grin worker arms depend on. +#[tokio::test] +async fn grin_tip_db_lifecycle_columns() { + let Some(app) = grin_tips_app().await else { return }; + let uid = app.create_user().await; + let commit = grin_commit(); + + use smirk_backend_core::infra::db::NewSocialTip; + let new = NewSocialTip { + sender_user_id: uid, + asset: "grin", + amount: 500, + claim_key_hash: Some(CLAIM_HASH), + encrypted_key: None, + tip_address: Some(&commit), + funding_txid: None, + tip_view_key: None, + confirmations_required: 10, + grin_commitment: Some(&commit), + }; + let tip_id = app.state.db.create_draft_social_tip(new).await.expect("create draft").id; + + // attach-funding with the client's bookkeeping id (grin has no real txid). + let r = app + .state + .db + .attach_funding_to_tip(tip_id, uid, "grin-bookkeeping-uuid") + .await + .expect("attach"); + assert_eq!(r.status, "pending_confirmation"); + assert_eq!(r.grin_commitment.as_deref(), Some(commit.as_str())); + + // Simulate the funding worker: 10 confs reached + amount auto-verified. + app.state.db.update_tip_confirmations(tip_id, 10).await.expect("confs"); + let verified = app + .state + .db + .mark_tip_funding_verified(tip_id, 500) + .await + .expect("verify") + .expect("row"); + assert_eq!(verified.status, "pending"); + assert!(verified.funding_amount_verified); + + // Claim, then record the sweep's kernel excess as sweep_txid. + let claimant = app.create_user().await; + app.state.db.mark_tip_claiming(tip_id, claimant).await.expect("claim").expect("row"); + let excess = format!("08{}", "bc".repeat(32)); // kernel excess (66 hex) + let swept = app + .state + .db + .confirm_tip_sweep(tip_id, claimant, &excess) + .await + .expect("confirm sweep") + .expect("row"); + assert_eq!(swept.status, "claiming"); + assert_eq!(swept.sweep_txid.as_deref(), Some(excess.as_str())); +}