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
+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 {