mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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.
This commit is contained in:
+187
-5
@@ -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<Arc<GrinClient>>`, 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<u64, &'static
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure depth-then-amount decision for a Grin output the recipient's `rewind_hash`
|
||||
/// recognized. Below the coin's confirmation depth is `not_confirmed`; a recognized
|
||||
/// output whose value is short is `amount_too_low`; otherwise the observed value is
|
||||
/// returned. `not_found` (no recognized output at all) is decided by the caller
|
||||
/// before this. Split out so it is unit-testable without a live wallet.
|
||||
fn grin_amount_outcome(
|
||||
value: u64,
|
||||
confs: u64,
|
||||
needed: u64,
|
||||
expected: u64,
|
||||
) -> Result<u64, &'static str> {
|
||||
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<VerifyResponse, AppError> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user