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).
This commit is contained in:
jwinterm
2026-07-10 18:40:56 -04:00
parent 5d8d5e193f
commit 1d6d67faa5
12 changed files with 695 additions and 30 deletions
@@ -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);
+3 -4
View File
@@ -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))
}
+35 -5
View File
@@ -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 {
+11 -3
View File
@@ -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<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// 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<String>,
}
/// 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)
+81 -2
View File
@@ -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<Vec<GrinOutputInfo>, AppError> {
if commits.is_empty() {
return Ok(Vec::new());
}
self.node_rpc(
&self.node_foreign_api_url,
self.foreign_auth(),
"get_outputs",
(
commits,
Option::<u64>::None,
Option::<u64>::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<Option<u64>, AppError> {
let kernel: Option<Option<GrinKernelInfo>> = self
.node_rpc_optional(
&self.node_foreign_api_url,
self.foreign_auth(),
"get_kernel",
(excess, Option::<u64>::None, Option::<u64>::None),
)
.await?;
// Outer Option = Ok vs inner-Err(NotFound); inner Option = the API's
// `Option<LocatedTxKernel>` (`{"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
}
}
+40
View File
@@ -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<u64>,
/// 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,
}
+34
View File
@@ -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<T, R>(
&self,
url: &str,
auth: Option<(&str, &str)>,
method: &str,
params: T,
) -> Result<Option<R>, AppError>
where
T: Serialize,
R: DeserializeOwned,
{
let bytes = self
.post_capped(url, auth, &JsonRpcRequest::new(method, params))
.await?;
let env: RpcEnvelope<R> = 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<B: Serialize>(
+3 -3
View File
@@ -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);
}
}
+154 -9
View File
@@ -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<AppState>) {
}
}
// 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 }
);
}
}
+15
View File
@@ -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<String> {
tip.grin_commitment
.as_deref()
.or(tip.tip_address.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
+84 -3
View File
@@ -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<ConfirmedSpend>, 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);
+223 -1
View File
@@ -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::TestApp> {
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::<uuid::Uuid>().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::<uuid::Uuid>().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()));
}