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.
This commit is contained in:
2ro
2026-07-04 21:39:48 -04:00
parent 580c313e6d
commit 2b791a18a6
3 changed files with 182 additions and 20 deletions
Generated
+1
View File
@@ -1841,6 +1841,7 @@ dependencies = [
"rand 0.6.5",
"serde",
"serde_json",
"uuid",
]
[[package]]
+5
View File
@@ -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
+176 -20
View File
@@ -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<String>,
}
/// 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<Sender<StatusMessage>> = 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<Issued, WalletError> {
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<FinalizedInvoice, WalletError> {
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<Sender<StatusMessage>> = 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<String, WalletError> {
let channel: Option<Sender<StatusMessage>> = 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();