mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user