From f26b4329991bc91dd50230d7e6d3765966bc4194 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 1 Aug 2026 07:36:34 -0400 Subject: [PATCH] feat(verify): implement grin (Mimblewimble) settlement verification Wire the family="grin" arm of POST /api/v1/verify, replacing the stub. Grin has no address and no txid, so the request identifies a payment by its output COMMITMENT (tx_ref) and the recipient's rewind_hash (view_key). scan_rewind_hash proves recipient ownership AND the true received amount in one scan (only the recipient's rewind_hash recognizes the commitment), then gates on grin's 10-confirmation depth via get_height. No get_kernel: MW aggregation/cut-through leaves no on-chain join binding a kernel excess to a specific output, so a kernel-only check can't soundly gate amount or recipient. Sender is unenforceable (MW reveals none -> sender:null) and dest_address is ignored (no addresses); a missing view_key returns not_found, never a false verify, mirroring the cryptonote arm. Adds grin_for resolver + grin_amount_outcome helper + unit tests. 8/8 verify tests pass, clippy and rustfmt clean. --- src/api/verify.rs | 192 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 5 deletions(-) diff --git a/src/api/verify.rs b/src/api/verify.rs index ca3d366..beb0623 100644 --- a/src/api/verify.rs +++ b/src/api/verify.rs @@ -25,9 +25,10 @@ use axum::{extract::State, routing::post, Json, Router}; use serde::{Deserialize, Serialize}; use tracing::{instrument, warn}; -use crate::api::wallet::{validate_cn_address, validate_hex, validate_view_key}; +use crate::api::wallet::{validate_cn_address, validate_hex, validate_hex64, validate_view_key}; use crate::error::AppError; use crate::infra::electrum::ElectrumClient; +use crate::infra::grin::GrinClient; use crate::infra::lws::LwsClient; use crate::models::tip_status::confirmations_for_asset; use crate::AppState; @@ -159,6 +160,24 @@ fn lws_for<'a>(state: &'a AppState, coin: &str) -> Result<&'a LwsClient, AppErro client.ok_or_else(|| AppError::ValidationError(format!("{coin} not enabled"))) } +/// Resolve the Grin client for the `grin` coin, or a validation error when the coin +/// is unknown or Grin is not enabled on this instance. `state.chains.grin` is an +/// `Option>`, so this uses `.as_deref()` (unlike the non-`Arc` +/// `electrum_for`/`lws_for`). A disabled/absent client is a non-2xx (400), never a +/// false `not_found`. +fn grin_for<'a>(state: &'a AppState, coin: &str) -> Result<&'a GrinClient, AppError> { + if coin != "grin" { + return Err(AppError::ValidationError(format!( + "Invalid Grin coin: {coin} (expected grin)" + ))); + } + state + .chains + .grin + .as_deref() + .ok_or_else(|| AppError::ValidationError(format!("{coin} not enabled"))) +} + /// Pure amount decision for a UTXO payment to the destination. `0` received means /// the tx did not pay the destination at all (`destination_mismatch`); a positive /// receipt below `expected` is `amount_too_low`; otherwise the observed amount is @@ -173,6 +192,26 @@ fn utxo_amount_outcome(paid_to_dest: u64, expected: u64) -> Result Result { + if confs < needed { + Err(reason::NOT_CONFIRMED) + } else if value < expected { + Err(reason::AMOUNT_TOO_LOW) + } else { + Ok(value) + } +} + // ── per-family verification ─────────────────────────────────────────────────── /// Verify a BTC/LTC payment named by `tx_ref` to `dest_address`. @@ -272,6 +311,73 @@ async fn verify_cryptonote( Ok(VerifyResponse::verified(tx.total_received, None)) } +/// Verify a Grin (Mimblewimble) payment named by its output COMMITMENT (`tx_ref`). +/// +/// Grin has NO address and NO txid: a received payment is a Pedersen output +/// commitment whose value is blinded, and only the recipient's `rewind_hash` VIEW +/// credential can recognize that output and read its value. We therefore REQUIRE +/// `view_key` (= the recipient `rewind_hash`); without it the output is invisible +/// and its amount unreadable, so we report `not_found` — never a 500 and never a +/// false verify — exactly as `verify_cryptonote` does for a missing view key. +/// +/// `scan_rewind_hash` returns the outputs the credential owns (commitment, value in +/// nanogrin, inclusion height). We match `tx_ref` among them, gate on grin's 10-conf +/// depth, then compare the TRUE received value. Because only the true recipient's +/// credential recognizes the commitment, a match simultaneously proves destination +/// (recipient ownership) AND amount — the two facts a bare kernel excess cannot +/// prove, which is why we do NOT use `get_kernel` here (it adds a failure surface +/// and no soundness: Mimblewimble aggregation/cut-through leaves no on-chain join +/// binding a kernel excess to a specific output). +/// +/// SOUNDNESS: Mimblewimble reveals no sender, so `sender` is always `None` and +/// `expected_sender` is unenforceable. `dest_address` is meaningless (no addresses) +/// and is ignored; recipient binding IS the rewind match. Any wallet/node outage is +/// a `NodeError` (non-2xx, retryable), never a false negative. +async fn verify_grin( + state: &AppState, + coin: &str, + req: &VerifyRequest, + expected: u64, +) -> Result { + let grin = grin_for(state, coin)?; + + let Some(rewind_hash) = req.view_key.as_deref() else { + warn!( + %coin, + "grin verify requires the recipient rewind_hash (view_key) to recognize the output; none supplied — reporting not_found", + ); + return Ok(VerifyResponse::failed(reason::NOT_FOUND)); + }; + // tx_ref = the received output's Pedersen commitment (33 bytes = 66 hex), one + // byte longer than a 32-byte txid, so bound at 66 (a 64 cap would reject it). + validate_hex(&req.tx_ref, "tx_ref", 66)?; + validate_hex64(rewind_hash, "view_key")?; + + // Authoritative view-only scan (grin-wallet Owner API v3). A wallet/node error + // propagates as NodeError (non-2xx) so moon.vote retries — never verified:false. + let view = grin.scan_rewind_hash(rewind_hash, None).await?; + // Commitments are lowercase hex on-chain; compare case-insensitively so a mere + // casing difference is not a false not_found. + let Some(output) = view + .output_result + .iter() + .find(|o| o.commit.eq_ignore_ascii_case(&req.tx_ref)) + else { + // This credential recognizes no output with this commitment: either the + // payment never landed, or it did not go to this recipient. + return Ok(VerifyResponse::failed(reason::NOT_FOUND)); + }; + + let tip = grin.get_height().await?; + let needed = confirmations_for_asset(coin).max(0) as u64; + + match grin_amount_outcome(output.value, output.confirmations(tip), needed, expected) { + // Mimblewimble carries no sender. + Ok(observed) => Ok(VerifyResponse::verified(observed, None)), + Err(r) => Ok(VerifyResponse::failed(r)), + } +} + // ── handler ─────────────────────────────────────────────────────────────────── /// Delegated settlement verification. UNAUTHENTICATED (see module docs); network- @@ -297,10 +403,7 @@ pub async fn verify_handler( let resp = match family.as_str() { "utxo" => verify_utxo(&state, &coin, &req, expected).await?, "cryptonote" => verify_cryptonote(&state, &coin, &req, expected).await?, - "grin" => { - warn!(%coin, "grin verify not yet implemented — reporting not_found"); - VerifyResponse::failed(reason::NOT_FOUND) - } + "grin" => verify_grin(&state, &coin, &req, expected).await?, other => { return Err(AppError::ValidationError(format!( "unknown settlement family: {other}" @@ -404,4 +507,83 @@ mod tests { .unwrap(); assert_eq!(req.view_key.as_deref(), Some("0f")); } + + #[test] + fn grin_amount_outcome_maps_to_reasons() { + // Below depth (needed=10) is not_confirmed, checked before amount. + assert_eq!( + grin_amount_outcome(1000, 9, 10, 500), + Err(reason::NOT_CONFIRMED) + ); + // Confirmed but short. + assert_eq!( + grin_amount_outcome(499, 10, 10, 500), + Err(reason::AMOUNT_TOO_LOW) + ); + // Exact and over both verify, echoing the observed nanogrin value. + assert_eq!(grin_amount_outcome(500, 10, 10, 500), Ok(500)); + assert_eq!( + grin_amount_outcome(1_000_000_000, 11, 10, 500), + Ok(1_000_000_000) + ); + // Depth is gated before amount even when the amount is fine. + assert_eq!( + grin_amount_outcome(500, 0, 10, 500), + Err(reason::NOT_CONFIRMED) + ); + } + + #[test] + fn grin_output_confirmations_feed_the_outcome() { + // The real depth path: build the output type and derive confs against a tip. + use crate::infra::grin::ViewWalletOutputResult; + let out = ViewWalletOutputResult { + commit: "09abcd".into(), + value: 750, + height: 100, + mmr_index: 0, + is_coinbase: false, + lock_height: 0, + }; + // tip 100 -> 1 conf -> below 10 -> not_confirmed. + assert_eq!( + grin_amount_outcome(out.value, out.confirmations(100), 10, 500), + Err(reason::NOT_CONFIRMED) + ); + // tip 120 -> 21 confs -> verified, echoing the observed value. + assert_eq!( + grin_amount_outcome(out.value, out.confirmations(120), 10, 500), + Ok(750) + ); + } + + #[test] + fn grin_request_deserializes_moonvote_wire_shape() { + // The grin request: tx_ref = output commitment (66 hex), view_key = + // recipient rewind_hash (64 hex), dest_address null (grin has no addresses). + let req: VerifyRequest = serde_json::from_str( + r#"{ + "coin":"grin","family":"grin","network":"grin-mainnet", + "tx_ref":"090000000000000000000000000000000000000000000000000000000000000000", + "amount":"1000000000","destination":"pay_operator", + "dest_address":null,"expected_sender":"","ref_id":null, + "view_key":"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + }"#, + ) + .unwrap(); + assert_eq!(req.coin, "grin"); + assert_eq!(req.family, "grin"); + assert_eq!(req.amount, "1000000000"); // 1 GRIN in nanogrin + assert!(req.dest_address.is_none(), "grin has no address"); + assert_eq!( + req.tx_ref.len(), + 66, + "output commitment is 33 bytes = 66 hex" + ); + assert_eq!( + req.view_key.as_deref().map(str::len), + Some(64), + "rewind_hash is 32 bytes = 64 hex" + ); + } }