From 2b791a18a6f90e994fefd75a903434e2ba2ae290 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:39:48 -0400 Subject: [PATCH] gp-wallet: native invoice-flow primitives (issue / finalize+post / cancel) Add the receiver-initiated invoice-flow half of a standard Grin interactive transaction on the upstream grin-wallet stack: - issue_invoice(amount): owner::issue_invoice_tx -> stored context keyed by slate id, returns the armored I1 slatepack (plain armor) + slate id. - finalize_invoice_slatepack(i2): loads context by slate id, owner::finalize_tx, then post_tx over DIRECT HTTP; returns slate id + kernel excess for the ledger. Non-I2 / unknown-context slates error cleanly (no double-post). - cancel(slate_id): owner::cancel_tx on expiry so a late I2 cannot settle. Kernel-excess read factored into a shared helper (reused by receive_slatepack). uuid 0.8 pinned to grin-wallet's line for slate-id parsing. Offline error-path unit tests added; full cross-wallet round trip is a deploy-time gate. --- Cargo.lock | 1 + crates/gp-wallet/Cargo.toml | 5 + crates/gp-wallet/src/lib.rs | 196 ++++++++++++++++++++++++++++++++---- 3 files changed, 182 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 713fc94..1621708 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1841,6 +1841,7 @@ dependencies = [ "rand 0.6.5", "serde", "serde_json", + "uuid", ] [[package]] diff --git a/crates/gp-wallet/Cargo.toml b/crates/gp-wallet/Cargo.toml index bf26a38..3ff56a3 100644 --- a/crates/gp-wallet/Cargo.toml +++ b/crates/gp-wallet/Cargo.toml @@ -34,6 +34,11 @@ ed25519-dalek = "=1.0.1" serde = { workspace = true } serde_json = { workspace = true } +# Slate id parsing for cancel_tx (invoice expiry). Pinned to the exact line +# grin_wallet_libwallet uses (its `uuid = "0.8"`), so the `Uuid` type unifies +# with the one cancel_tx / retrieve_txs accept. +uuid = "0.8" + [dev-dependencies] # The exact node API type HTTPNodeClient parses a get_kernel response into. # Used only to assert the confirmation parser against a recorded node fixture diff --git a/crates/gp-wallet/src/lib.rs b/crates/gp-wallet/src/lib.rs index 4c4df37..dcb45d0 100644 --- a/crates/gp-wallet/src/lib.rs +++ b/crates/gp-wallet/src/lib.rs @@ -36,7 +36,7 @@ use grin_util::secp::key::SecretKey; use grin_util::{Mutex, ZeroingString}; use grin_wallet_impls::{DefaultLCProvider, DefaultWalletImpl, HTTPNodeClient}; use grin_wallet_libwallet::api_impl::{foreign, owner}; -use grin_wallet_libwallet::{SlateState, StatusMessage, WalletInst}; +use grin_wallet_libwallet::{IssueInvoiceTxArgs, SlateState, StatusMessage, WalletInst}; use serde::Serialize; pub use confirm::{confirm_status, ConfirmStatus}; @@ -99,6 +99,31 @@ pub struct Received { pub proof: Option, } +/// Result of issuing an invoice (native invoice flow, receiver-initiated): the +/// I1 slatepack armor for the payer plus the slate id we key settlement on. +#[derive(Debug, Clone)] +pub struct Issued { + /// Slate UUID, shared by I1, I2, and the final transaction. Stored on the + /// invoice row so a returning I2 is matched back to this invoice. + pub slate_id: String, + /// The armored I1 invoice slatepack (standard armor) the payer imports to + /// pay. Plain armor, like the receive path; no custom transforms. + pub i1_armor: String, +} + +/// Result of finalizing a returned invoice slate (I2): the completed tx has +/// been posted to the node. Carries what the ledger records for the sale. +#[derive(Debug, Clone)] +pub struct FinalizedInvoice { + /// Slate UUID (matches the invoice's stored `slate_id`). + pub slate_id: String, + /// Amount in nanogrin the invoice was issued for. + pub amount: u64, + /// Tx kernel excess commitment, hex (33 bytes) — the on-chain anchor the + /// confirmation poll queries with `get_kernel`. + pub kernel_excess: String, +} + /// Cheap wallet balance snapshot (nanogrin), read from the local DB without a /// node scan (the heavy updater stays disabled per the plan). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -271,25 +296,7 @@ impl GpWallet { // so it equals both the excess receive_tx signed into the payment proof // and the on-chain kernel excess the node returns — the single anchor // for confirmation and proof. - let kernel_excess = { - let channel: Option> = None; - let (_refreshed, txs) = owner::retrieve_txs( - self.instance.clone(), - self.mask.as_ref(), - &channel, - false, // local read only; the heavy updater stays disabled - None, - Some(slate.id), - None, - )?; - let excess = txs - .iter() - .find_map(|t| t.kernel_excess.as_ref()) - .ok_or_else(|| { - WalletError::Wallet("received tx has no recorded kernel excess".into()) - })?; - proof::encode_hex(&excess.0) - }; + let kernel_excess = self.slate_kernel_excess(slate.id)?; // If the payer's S1 requested a payment proof, receive_tx has filled in // the receiver signature on the returned slate; capture the full @@ -315,6 +322,136 @@ impl GpWallet { }) } + /// Issue an invoice (native invoice flow, we are the receiver-initiator): + /// run `issue_invoice_tx` for `amount` nanogrin, storing the aggsig context + /// keyed by the slate id, and return the armored I1 slatepack for the payer + /// plus that slate id. The payer imports the I1, pays it (producing an I2), + /// and returns the I2 for [`GpWallet::finalize_invoice_slatepack`]. + /// + /// This touches the node once (`issue_invoice_tx` reads the chain tip to + /// stamp the slate height); that read goes DIRECT over HTTP, like every + /// other node read here. + pub fn issue_invoice(&self, amount: u64) -> Result { + let args = IssueInvoiceTxArgs { + amount, + ..Default::default() + }; + let i1 = { + let mut w_lock = self.instance.lock(); + let lc = w_lock.lc_provider()?; + let w = lc.wallet_inst()?; + owner::issue_invoice_tx(&mut **w, self.mask.as_ref(), args, false)? + }; + if i1.state != SlateState::Invoice1 { + return Err(WalletError::Wallet(format!( + "issue_invoice_tx produced unexpected slate state {:?}", + i1.state + ))); + } + // Plain armor (like the receive path); the QR carries the same text. + let i1_armor = owner::create_slatepack_message( + self.instance.clone(), + self.mask.as_ref(), + &i1, + Some(0), + vec![], + )?; + Ok(Issued { + slate_id: i1.id.to_string(), + i1_armor, + }) + } + + /// Finalize a returned invoice slate (I2) and post the completed tx to the + /// node. Loads our stored context by slate id (saved at + /// [`GpWallet::issue_invoice`]), completes the tx, POSTS it, and returns the + /// slate id + kernel excess for the ledger. A returned slate that is not an + /// I2 (wrong state, garbage, or one whose context we never stored) errors + /// cleanly, so a late finalize after an expiry cancel fails rather than + /// double-posting. + pub fn finalize_invoice_slatepack( + &self, + i2_armor: &str, + ) -> Result { + let i2 = owner::slate_from_slatepack_message( + self.instance.clone(), + self.mask.as_ref(), + i2_armor.trim().to_string(), + vec![0], + ) + .map_err(|e| WalletError::Slatepack(format!("cannot read invoice slatepack: {e}")))?; + + if i2.state != SlateState::Invoice2 { + return Err(WalletError::Slatepack(format!( + "expected an I2 (invoice return) slatepack, got {:?}", + i2.state + ))); + } + let slate_id = i2.id.to_string(); + let amount = i2.amount; + + // Complete the tx from our stored context (errors if we never issued + // this invoice or already cancelled it), then post it ourselves. + let final_slate = { + let mut w_lock = self.instance.lock(); + let lc = w_lock.lc_provider()?; + let w = lc.wallet_inst()?; + owner::finalize_tx(&mut **w, self.mask.as_ref(), &i2)? + }; + let client = HTTPNodeClient::new(&self.node_url, None).map_err(|e| { + WalletError::Config(format!("bad node URL `{}`: {e}", self.node_url)) + })?; + owner::post_tx(&client, final_slate.tx_or_err()?, true)?; + + // Kernel excess from the tx log (offset-independent, summed over both + // participants), same anchor the receive path records. + let kernel_excess = self.slate_kernel_excess(final_slate.id)?; + Ok(FinalizedInvoice { + slate_id, + amount, + kernel_excess, + }) + } + + /// Cancel the stored context for a slate id (invoice expiry): a subsequent + /// `finalize_invoice_slatepack` for the same slate then fails cleanly (no + /// stored context), so a late payer I2 cannot settle an expired invoice. + /// `cancel_tx` contacts the node to confirm state; a node hiccup surfaces as + /// an error the sweeper logs and retries. + pub fn cancel(&self, slate_id: &str) -> Result<(), WalletError> { + let uuid = uuid::Uuid::parse_str(slate_id) + .map_err(|e| WalletError::Wallet(format!("bad slate id `{slate_id}`: {e}")))?; + let channel: Option> = None; + owner::cancel_tx( + self.instance.clone(), + self.mask.as_ref(), + &channel, + None, + Some(uuid), + )?; + Ok(()) + } + + /// The tx kernel excess (hex) the wallet logged for `slate_id`, read from + /// the local tx log (no node scan). Shared by receive and invoice finalize. + fn slate_kernel_excess(&self, slate_id: uuid::Uuid) -> Result { + let channel: Option> = None; + let (_refreshed, txs) = owner::retrieve_txs( + self.instance.clone(), + self.mask.as_ref(), + &channel, + false, + None, + Some(slate_id), + None, + )?; + let excess = txs + .iter() + .find_map(|t| t.kernel_excess.as_ref()) + .ok_or_else(|| WalletError::Wallet("tx has no recorded kernel excess".into()))?; + Ok(proof::encode_hex(&excess.0)) + } + /// Extract the receiver-side payment proof from a post-`receive_tx` slate, /// serialized to JSON. Returns `None` when the slate carried no proof /// request (or, defensively, no receiver signature). @@ -507,6 +644,25 @@ mod tests { assert!(matches!(err, Err(WalletError::Slatepack(_)))); } + #[test] + fn finalize_invoice_rejects_garbage_armor() { + // Parse-before-node: a malformed I2 armor is rejected without any node + // contact, so this runs offline. + let dir = TempDir::new("fin-garbage"); + let wallet = open(&dir, &random_mnemonic()).unwrap(); + let err = wallet.finalize_invoice_slatepack("BEGINSLATEPACK. nope. ENDSLATEPACK."); + assert!(matches!(err, Err(WalletError::Slatepack(_))), "got {err:?}"); + } + + #[test] + fn cancel_rejects_bad_slate_id() { + // A non-UUID slate id is rejected before any node contact. + let dir = TempDir::new("cancel-bad"); + let wallet = open(&dir, &random_mnemonic()).unwrap(); + let err = wallet.cancel("not-a-uuid"); + assert!(matches!(err, Err(WalletError::Wallet(_))), "got {err:?}"); + } + #[test] fn open_from_config_requires_both_secrets() { let cfg = Config::default();