From 53bc6d3e3e275461fe1f6886406700bdf2a41a74 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 22 Jun 2026 11:29:48 -0400 Subject: [PATCH] wallet: auto-reply over Tor when scanning an Invoice1 slatepack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing Standard1 + Send-task flow for the invoice direction. When a customer scans an Invoice1 slatepack QR with an embedded sender address (now standard for slatepacks produced by grin-wallet's issue_invoice_tx), the patched pay() forwards the sender address through create_slatepack_message, and the OpenMessage handler — if the wallet's Tor service is running or starting — pushes the signed Invoice2 to the sender's foreign-api finalize_tx over Tor. The merchant's wallet finalizes + posts on their side, so no local finalize/post is needed (cf. the existing send_tor closure for Standard1 which does need that). Backward-compatible: if the slatepack has no embedded sender address (older clients) or the Tor service isn't up, the existing write-slatepack-to-disk-for-paste-back fallback runs unchanged. No protocol change, no new dependencies, no new failure modes — the response slatepack file is always written before the Tor send is attempted, so a Tor failure mid-flight is recoverable. Closes the mobile-UX gap that required the customer to manually copy the response slatepack from the wallet and paste it back to the merchant's web/storefront interface. With this patch and a foreign-api listener on the merchant side, scanning a Grin invoice QR is now a single tap: scan → confirm → done. send_tor() gains a `finalize: bool` parameter that selects between the existing receive_tx body (for Standard1 sends) and a new finalize_tx body (for the invoice-flow case). The same Tor SOCKS plumbing handles both. Real-world validation: end-to-end working today on a production BTCPay deployment (Such Software's btcpayserver-plugin-grin v1.3.5) — invoice QR scan with a patched build settles a merchant invoice in ~1 confirmation window with zero customer interaction beyond the scan. --- src/wallet/wallet.rs | 85 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index a26692ce..da1e43aa 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -1070,23 +1070,41 @@ impl Wallet { } } - /// Send slate to Tor address. - async fn send_tor(&self, id: u32, s: &Slate, addr: &SlatepackAddress) -> Result { + /// Send slate to Tor address. When `finalize` is true, posts the slate to the + /// peer's foreign-api `finalize_tx` (used by the invoice-flow payer to push the + /// signed Invoice2 back to the merchant for broadcast); otherwise posts to + /// `receive_tx` (standard send flow). + async fn send_tor( + &self, + id: u32, + s: &Slate, + addr: &SlatepackAddress, + finalize: bool, + ) -> Result { self.on_tx_action(id, Some(WalletTxAction::SendingTor)); let tor_addr = OnionV3Address::try_from(addr).unwrap().to_http_str(); let url = format!("{}/v2/foreign", tor_addr); let slate_send = VersionedSlate::into_version(s.clone(), SlateVersion::V4)?; - let body = json!({ - "jsonrpc": "2.0", - "method": "receive_tx", - "id": 1, - "params": [ - slate_send, - null, - null - ] - }) + let body = if finalize { + json!({ + "jsonrpc": "2.0", + "method": "finalize_tx", + "id": 1, + "params": [slate_send] + }) + } else { + json!({ + "jsonrpc": "2.0", + "method": "receive_tx", + "id": 1, + "params": [ + slate_send, + null, + null + ] + }) + } .to_string(); // Wait Tor service to launch. while Tor::is_service_starting(&self.identifier()) { @@ -1136,7 +1154,7 @@ impl Wallet { } /// Handle message from the invoice issuer to send founds, return response for funds receiver. - fn pay(&self, slate: &Slate) -> Result { + fn pay(&self, slate: &Slate, dest: Option) -> Result { let config = self.get_config(); let args = InitTxArgs { src_acct_name: None, @@ -1151,8 +1169,9 @@ impl Wallet { let slate = api.process_invoice_tx(self.keychain_mask().as_ref(), &slate, args)?; api.tx_lock_outputs(self.keychain_mask().as_ref(), &slate)?; - // Create Slatepack message response. - let _ = self.create_slatepack_message(&slate, None)?; + // Create Slatepack message response (kept as on-disk paste-back fallback even + // when an auto-Tor reply is attempted by the caller). + let _ = self.create_slatepack_message(&slate, dest)?; Ok(slate) } @@ -1744,7 +1763,7 @@ fn start_sync(wallet: Wallet) -> Thread { /// Handle wallet task. async fn handle_task(w: &Wallet, t: WalletTask) { let send_tor = async |tx: TxLogEntry, s: &Slate, r: &SlatepackAddress| match w - .send_tor(tx.id, &s, r) + .send_tor(tx.id, &s, r, false) .await { Ok(s) => match w.finalize(&s, tx.id) { @@ -1769,6 +1788,23 @@ async fn handle_task(w: &Wallet, t: WalletTask) { w.on_task_result(Some(tx), &t); } }; + // Invoice-flow counterpart to send_tor. After signing an Invoice2 the payer + // posts the slate to the merchant's foreign-api finalize_tx; the merchant + // finalizes + broadcasts on their side, so no local finalize/post is needed. + let pay_tor = async |tx: TxLogEntry, s: &Slate, r: &SlatepackAddress| match w + .send_tor(tx.id, &s, r, true) + .await + { + Ok(_) => { + sync_wallet_data(&w, false); + w.on_task_result(Some(tx), &t); + } + Err(e) => { + error!("pay tor error: {:?}", e); + w.on_tx_error(tx.id, Some(e)); + w.on_task_result(Some(tx), &t); + } + }; match &t { WalletTask::OpenMessage(m) => { if !w.is_open() || m.is_empty() { @@ -1804,9 +1840,24 @@ async fn handle_task(w: &Wallet, t: WalletTask) { match s.state { SlateState::Standard1 | SlateState::Invoice1 => { if s.state != SlateState::Standard1 { - if let Ok(_) = w.pay(&s) { + // Invoice1: sign + lock outputs. If the slatepack embedded a + // sender address and our Tor service is up, also push the + // signed Invoice2 back to the merchant for broadcast — the + // on-disk slatepack is still written as a paste-back fallback. + if let Ok(signed) = w.pay(&s, dest.clone()) { sync_wallet_data(&w, false); let tx = w.retrieve_tx_by_id(None, Some(s.id)); + if let Some(addr) = dest { + let id = w.identifier(); + if tx.is_some() + && (Tor::is_service_running(&id) + || Tor::is_service_starting(&id)) + { + w.message_opening.store(false, Ordering::Relaxed); + pay_tor(tx.unwrap(), &signed, &addr).await; + return; + } + } w.on_task_result(tx, &t); } } else {