mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +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:
@@ -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);
|
||||||
@@ -334,14 +334,13 @@ fn feed_advertised(config: &Config) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether public tips are actually serveable: the feature is on, a share-URL
|
/// 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
|
/// 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
|
/// tips an instance can't escrow on or build share links for.
|
||||||
/// the tips port, so it is not a supported tip chain here.)
|
|
||||||
fn tips_advertised(config: &Config) -> bool {
|
fn tips_advertised(config: &Config) -> bool {
|
||||||
config.features.tips
|
config.features.tips
|
||||||
&& config.tip_share_base.is_some()
|
&& config.tip_share_base.is_some()
|
||||||
&& ["btc", "ltc", "xmr", "wow"]
|
&& ["btc", "ltc", "xmr", "wow", "grin"]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|asset| chain_serviceable(config, asset))
|
.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
|
/// Whether `asset` is a tip-capable chain enabled on this instance.
|
||||||
/// supported by the tips port yet.
|
|
||||||
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
||||||
let c = &state.config.features.chains;
|
let c = &state.config.features.chains;
|
||||||
match asset {
|
match asset {
|
||||||
@@ -178,14 +177,16 @@ fn supported_tip_asset(state: &AppState, asset: &str) -> bool {
|
|||||||
"ltc" => c.ltc,
|
"ltc" => c.ltc,
|
||||||
"xmr" => c.xmr,
|
"xmr" => c.xmr,
|
||||||
"wow" => c.wow,
|
"wow" => c.wow,
|
||||||
|
"grin" => c.grin,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Basic tip-address validation. XMR/WOW reuse the CryptoNote validator; BTC/LTC
|
/// 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
|
/// get a light sanity check; grin's "address" is the voucher's 66-hex Pedersen
|
||||||
/// address, so a malformed one merely fails funding verification — it can't
|
/// commitment (the sender funds and later sweeps THEIR OWN output, so a malformed
|
||||||
/// misdirect funds to a third party).
|
/// one merely fails funding verification — it can't misdirect funds to a third
|
||||||
|
/// party).
|
||||||
fn validate_tip_address(asset: &str, address: &str) -> Result<(), AppError> {
|
fn validate_tip_address(asset: &str, address: &str) -> Result<(), AppError> {
|
||||||
if address.is_empty() || address.len() > 255 {
|
if address.is_empty() || address.len() > 255 {
|
||||||
return Err(AppError::ValidationError("tip_address is invalid.".into()));
|
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()))
|
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())),
|
_ => Err(AppError::ValidationError("unsupported tip asset.".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,6 +344,24 @@ pub async fn create_social_tip(
|
|||||||
validate_tip_address(&asset, addr)?;
|
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 {
|
let new = NewSocialTip {
|
||||||
sender_user_id: user_id,
|
sender_user_id: user_id,
|
||||||
asset: &asset,
|
asset: &asset,
|
||||||
@@ -343,6 +372,7 @@ pub async fn create_social_tip(
|
|||||||
funding_txid,
|
funding_txid,
|
||||||
tip_view_key: req.tip_view_key.as_deref().filter(|s| !s.is_empty()),
|
tip_view_key: req.tip_view_key.as_deref().filter(|s| !s.is_empty()),
|
||||||
confirmations_required: confirmations_for_asset(&asset),
|
confirmations_required: confirmations_for_asset(&asset),
|
||||||
|
grin_commitment,
|
||||||
};
|
};
|
||||||
|
|
||||||
let tip = if is_draft {
|
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, \
|
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, \
|
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, \
|
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
|
/// A persisted public social tip. Field names/order match [`TIP_COLS`] (sqlx
|
||||||
/// `FromRow` maps by name).
|
/// `FromRow` maps by name).
|
||||||
@@ -63,6 +63,10 @@ pub struct SocialTipRow {
|
|||||||
pub lws_deactivated_at: Option<DateTime<Utc>>,
|
pub lws_deactivated_at: Option<DateTime<Utc>>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_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
|
/// 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 funding_txid: Option<&'a str>,
|
||||||
pub tip_view_key: Option<&'a str>,
|
pub tip_view_key: Option<&'a str>,
|
||||||
pub confirmations_required: i32,
|
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
|
/// 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!(
|
let row = sqlx::query_as::<_, SocialTipRow>(&format!(
|
||||||
"INSERT INTO social_tips \
|
"INSERT INTO social_tips \
|
||||||
(sender_user_id, asset, amount, is_public, claim_key_hash, encrypted_key, \
|
(sender_user_id, asset, amount, is_public, claim_key_hash, encrypted_key, \
|
||||||
tip_address, funding_txid, status, confirmations_required, tip_view_key) \
|
tip_address, funding_txid, status, confirmations_required, tip_view_key, \
|
||||||
VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9, $10) \
|
grin_commitment) \
|
||||||
|
VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||||
RETURNING {TIP_COLS}"
|
RETURNING {TIP_COLS}"
|
||||||
))
|
))
|
||||||
.bind(new.sender_user_id)
|
.bind(new.sender_user_id)
|
||||||
@@ -141,6 +148,7 @@ impl Database {
|
|||||||
.bind(status.as_str())
|
.bind(status.as_str())
|
||||||
.bind(new.confirmations_required)
|
.bind(new.confirmations_required)
|
||||||
.bind(new.tip_view_key)
|
.bind(new.tip_view_key)
|
||||||
|
.bind(new.grin_commitment)
|
||||||
.fetch_one(self.pool())
|
.fetch_one(self.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
|
|||||||
+81
-2
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use super::{GrinClient, GrinStatus, ViewWallet};
|
use super::{GrinClient, GrinKernelInfo, GrinOutputInfo, GrinStatus, ViewWallet};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
impl GrinClient {
|
impl GrinClient {
|
||||||
@@ -86,6 +86,71 @@ impl GrinClient {
|
|||||||
Ok(self.get_status().await?.tip.height)
|
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
|
/// Broadcast a finalized transaction via the node Foreign API
|
||||||
/// (`push_transaction`). The wallet builds + signs locally; the backend only
|
/// (`push_transaction`). The wallet builds + signs locally; the backend only
|
||||||
/// relays. `tx` is the finalized transaction object.
|
/// relays. `tx` is the finalized transaction object.
|
||||||
@@ -113,7 +178,7 @@ impl GrinClient {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::GrinConfig;
|
use crate::config::GrinConfig;
|
||||||
use crate::infra::grin::ViewWalletOutputResult;
|
use crate::infra::grin::{GrinOutputInfo, ViewWalletOutputResult};
|
||||||
|
|
||||||
fn cfg() -> GrinConfig {
|
fn cfg() -> GrinConfig {
|
||||||
GrinConfig {
|
GrinConfig {
|
||||||
@@ -149,4 +214,18 @@ mod tests {
|
|||||||
assert_eq!(out(0).confirmations(110), 0); // unconfirmed / no height
|
assert_eq!(out(0).confirmations(110), 0); // unconfirmed / no height
|
||||||
assert_eq!(out(120).confirmations(110), 0); // beyond tip -> not a bogus count
|
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 struct GrinTip {
|
||||||
pub height: u64,
|
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")
|
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
|
/// 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.
|
/// response bytes. The body is never logged or echoed into an error.
|
||||||
async fn post_capped<B: Serialize>(
|
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.
|
/// 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
|
/// XMR 10, GRIN 10, WOW 4, BTC/LTC 0 (immediately spendable).
|
||||||
/// the public-tips port yet.
|
|
||||||
pub const fn confirmations_for_asset(asset: &str) -> i32 {
|
pub const fn confirmations_for_asset(asset: &str) -> i32 {
|
||||||
match asset.as_bytes() {
|
match asset.as_bytes() {
|
||||||
b"xmr" => 10,
|
b"xmr" => 10,
|
||||||
|
b"grin" => 10,
|
||||||
b"wow" => 4,
|
b"wow" => 4,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
@@ -160,6 +160,6 @@ mod tests {
|
|||||||
assert_eq!(confirmations_for_asset("wow"), 4);
|
assert_eq!(confirmations_for_asset("wow"), 4);
|
||||||
assert_eq!(confirmations_for_asset("btc"), 0);
|
assert_eq!(confirmations_for_asset("btc"), 0);
|
||||||
assert_eq!(confirmations_for_asset("ltc"), 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.
|
//! Funding confirmation + amount verification for public social tips.
|
||||||
//!
|
//!
|
||||||
//! Ported (public subset) from the legacy `core/tip_confirmation.rs`. Grin and
|
//! Ported (public subset) from the legacy `core/tip_confirmation.rs`. All
|
||||||
//! all DM/announcement side-effects are dropped: public tips deliver by share
|
//! DM/announcement side-effects are dropped: public tips deliver by share URL,
|
||||||
//! URL, so the "fire the recipient a notification" branches become no-ops here.
|
//! so the "fire the recipient a notification" branches become no-ops here.
|
||||||
//!
|
//!
|
||||||
//! Confirmation thresholds (see [`crate::models::tip_status::confirmations_for_asset`]):
|
//! 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
|
//! XMR 10, GRIN 10, WOW 4, BTC/LTC 0. BTC/LTC skip the confirmation-count pass
|
||||||
//! and go straight to amount verification.
|
//! 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 std::sync::Arc;
|
||||||
|
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
|
use super::grin_commitment;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::infra::db::SocialTipRow;
|
use crate::infra::db::SocialTipRow;
|
||||||
use crate::infra::lws::AddressTxsResponse;
|
use crate::infra::lws::AddressTxsResponse;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// CryptoNote assets that require an on-chain confirmation count before the
|
/// 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"];
|
const CONFIRMATION_ASSETS: [&str; 2] = ["xmr", "wow"];
|
||||||
|
|
||||||
/// Run one full funding pass: confirmation counting for XMR/WOW, then the
|
/// 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 {
|
if let Err(e) = process_pending_verifications(&state).await {
|
||||||
warn!(error = %e, "tip funding-amount verification pass failed");
|
warn!(error = %e, "tip funding-amount verification pass failed");
|
||||||
}
|
}
|
||||||
@@ -93,6 +105,62 @@ async fn check_asset_confirmations(state: &AppState, asset: &str) -> Result<(),
|
|||||||
Ok(())
|
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
|
/// 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` /
|
/// sender-declared `amount`. The verifier acts on this; only `Verified` /
|
||||||
/// `Short` mutate status.
|
/// `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`.
|
/// Verify that a tip's on-chain funding covers `amount`.
|
||||||
///
|
///
|
||||||
/// XMR/WOW: `get_address_txs(tip_address, tip_view_key)` → `classify_funding_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 }
|
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 => {
|
other => {
|
||||||
// Only btc/ltc/xmr/wow can ever create a tip, so this is unreachable
|
// Only btc/ltc/xmr/wow/grin can ever create a tip, so this is
|
||||||
// in practice. Do NOT auto-verify an unknown asset (that would be a
|
// unreachable in practice. Do NOT auto-verify an unknown asset (that
|
||||||
// money-safety hole) — treat it as an error so the row stays put.
|
// 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");
|
warn!(tip_id = %tip.id, asset = other, "verify: unsupported asset — staying in pending_confirmation");
|
||||||
FundingVerification::LwsError
|
FundingVerification::LwsError
|
||||||
}
|
}
|
||||||
@@ -464,4 +575,38 @@ mod tests {
|
|||||||
fn lws_error_distinct_from_unfunded() {
|
fn lws_error_distinct_from_unfunded() {
|
||||||
assert_ne!(FundingVerification::LwsError, FundingVerification::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 confirmation::run_tip_confirmation_cycle;
|
||||||
pub use gc::{run_tip_draft_gc_cycle, run_tip_lifecycle_gc_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};
|
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`]).
|
//! winner ([`pick_winner`]).
|
||||||
//!
|
//!
|
||||||
//! ## Confirmation thresholds (mirror the funding thresholds)
|
//! ## 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
|
//! ## Money-safety invariants
|
||||||
//! - A probe ERROR (Electrum/LWS unreachable, hostile amount) NEVER settles or
|
//! - 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 tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
|
use super::grin_commitment;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::infra::db::SocialTipRow;
|
use crate::infra::db::SocialTipRow;
|
||||||
use crate::infra::electrum::VerboseTransaction;
|
use crate::infra::electrum::VerboseTransaction;
|
||||||
@@ -68,12 +76,13 @@ pub struct ConfirmedSpend {
|
|||||||
/// Per-asset sweep confirmation threshold (confirmations the winning spend needs
|
/// Per-asset sweep confirmation threshold (confirmations the winning spend needs
|
||||||
/// before we settle to `claimed`). Mirrors the funding-side thresholds. Unknown
|
/// before we settle to `claimed`). Mirrors the funding-side thresholds. Unknown
|
||||||
/// assets default to the most conservative (10) so a misconfigured asset never
|
/// 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 {
|
pub(crate) fn sweep_confirm_threshold(asset: &str) -> u64 {
|
||||||
match asset.to_lowercase().as_str() {
|
match asset.to_lowercase().as_str() {
|
||||||
"btc" | "ltc" => 1,
|
"btc" | "ltc" => 1,
|
||||||
"wow" => 4,
|
"wow" => 4,
|
||||||
"xmr" => 10,
|
"xmr" => 10,
|
||||||
|
"grin" => 10,
|
||||||
_ => 10,
|
_ => 10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,8 +310,9 @@ async fn probe_confirmed_spends(
|
|||||||
match asset.as_str() {
|
match asset.as_str() {
|
||||||
"btc" | "ltc" => probe_electrum_spends(state, tip, &asset).await,
|
"btc" | "ltc" => probe_electrum_spends(state, tip, &asset).await,
|
||||||
"xmr" | "wow" => probe_lws_spends(state, tip, &asset).await,
|
"xmr" | "wow" => probe_lws_spends(state, tip, &asset).await,
|
||||||
|
"grin" => probe_grin_spends(state, tip).await,
|
||||||
other => {
|
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");
|
warn!(tip_id = %tip.id, asset = %other, "sweep probe: unsupported asset");
|
||||||
Err(AppError::ValidationError(format!(
|
Err(AppError::ValidationError(format!(
|
||||||
"sweep probe: unsupported asset {other}"
|
"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`)
|
/// 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
|
/// 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
|
/// 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);
|
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]
|
#[test]
|
||||||
fn threshold_unknown_defaults_conservative() {
|
fn threshold_unknown_defaults_conservative() {
|
||||||
assert_eq!(sweep_confirm_threshold("doge"), 10);
|
assert_eq!(sweep_confirm_threshold("doge"), 10);
|
||||||
|
|||||||
+223
-1
@@ -110,13 +110,15 @@ async fn db_create_draft_roundtrips_all_columns() {
|
|||||||
funding_txid: None,
|
funding_txid: None,
|
||||||
tip_view_key: None,
|
tip_view_key: None,
|
||||||
confirmations_required: 0,
|
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");
|
let row = app.state.db.create_draft_social_tip(new).await.expect("create draft");
|
||||||
assert_eq!(row.status, "draft");
|
assert_eq!(row.status, "draft");
|
||||||
assert_eq!(row.amount, 100_000);
|
assert_eq!(row.amount, 100_000);
|
||||||
assert!(row.is_public);
|
assert!(row.is_public);
|
||||||
assert_eq!(row.encrypted_key.as_deref(), Some(&[0xcd, 0xcd][..]));
|
assert_eq!(row.encrypted_key.as_deref(), Some(&[0xcd, 0xcd][..]));
|
||||||
|
assert_eq!(row.grin_commitment, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -193,6 +195,7 @@ async fn make_draft(
|
|||||||
funding_txid: None,
|
funding_txid: None,
|
||||||
tip_view_key: view_key,
|
tip_view_key: view_key,
|
||||||
confirmations_required: conf,
|
confirmations_required: conf,
|
||||||
|
grin_commitment: None,
|
||||||
};
|
};
|
||||||
app.state
|
app.state
|
||||||
.db
|
.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_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");
|
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()));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user