diff --git a/src/wallet/config.rs b/src/wallet/config.rs index c512b527..56deb324 100644 --- a/src/wallet/config.rs +++ b/src/wallet/config.rs @@ -227,6 +227,14 @@ impl WalletConfig { path.to_str().unwrap().to_string() } + /// Path of the per-sale proof-address index registry (a JSON allocation + /// counter, no secrets — see [`crate::wallet::proof_addrs`]). + pub fn get_proof_addrs_path(&self) -> PathBuf { + let mut path = PathBuf::from(self.get_base_data_path()); + path.push("proof_addrs.json"); + path + } + /// Get nostr identity directory path (holds identity.json). pub fn get_nostr_path(&self) -> PathBuf { let mut path = PathBuf::from(self.get_base_data_path()); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d506d14a..c3b131de 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -35,5 +35,7 @@ pub use utils::WalletUtils; mod seed; pub mod store; +pub mod proof_addrs; + #[cfg(all(test, feature = "e2e-internal"))] mod e2e; diff --git a/src/wallet/proof_addrs.rs b/src/wallet/proof_addrs.rs new file mode 100644 index 00000000..05df08c8 --- /dev/null +++ b/src/wallet/proof_addrs.rs @@ -0,0 +1,122 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Per-sale proof-address INDEX allocator. One wallet has one slatepack/proof +//! address per derivation index (`address_from_derivation_path`); index 0 is +//! the app's default address and every per-offer/per-sale address is minted at +//! the next fresh index, so no two sales ever share an address. This registry +//! is just the persisted allocation counter — the keys themselves live in the +//! wallet seed (an index carries no secret; the file only reveals how many +//! addresses were minted). Indices are single-use and never reused: a burned +//! index (minted but the offer never completed) is simply skipped forever. +//! +//! The allocator must stay at or below the receive-side scan bound — the +//! (patched) wallet receive path detects which allocated address a +//! payment-proof slate is addressed to by scanning `0..=bound`, and an index +//! outside it could not be detected, so its proof would be signed with the +//! wrong key and fail the sender's check. + +use serde_derive::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Highest allocatable proof-address derivation index. MUST equal the wallet +/// receive path's scan bound (`address::MAX_PROOF_ADDRESS_INDEX` in the +/// grin-wallet submodule patch) — kept as a local constant so this crate also +/// builds against the unpatched upstream submodule. +pub const MAX_PROOF_ADDRESS_INDEX: u32 = 1023; + +/// The persisted allocation state: the next index to hand out. Index 0 is the +/// app's default address, so per-sale allocation starts at 1. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct ProofAddrRegistry { + ver: u8, + next: u32, +} + +impl Default for ProofAddrRegistry { + fn default() -> Self { + Self { ver: 1, next: 1 } + } +} + +/// Allocate the next fresh proof-address derivation index, persisting the +/// counter at `path` (a JSON file in the wallet's data dir). Starts at 1 +/// (index 0 is the default app address), increments monotonically, and +/// refuses to allocate past the receive-side scan bound. The counter is +/// persisted BEFORE the index is returned, so a crash after allocation burns +/// the index rather than ever reusing it. +pub fn allocate(path: &PathBuf) -> Result { + let mut reg: ProofAddrRegistry = std::fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + let index = reg.next; + if index > MAX_PROOF_ADDRESS_INDEX { + return Err("proof address space exhausted".to_string()); + } + reg.next = index + 1; + let raw = serde_json::to_string(®).map_err(|e| e.to_string())?; + std::fs::write(path, raw).map_err(|e| e.to_string())?; + Ok(index) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpfile(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "goblin-proofaddr-{tag}-{}-{:?}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn allocates_monotonically_from_one_and_persists() { + let path = tmpfile("mono"); + // Index 0 is the app address: per-sale minting starts at 1. + assert_eq!(allocate(&path).unwrap(), 1); + assert_eq!(allocate(&path).unwrap(), 2); + assert_eq!(allocate(&path).unwrap(), 3); + // Persistence: a fresh load (new call, same file) continues, never reuses. + assert_eq!(allocate(&path).unwrap(), 4); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn refuses_past_receive_scan_bound() { + let path = tmpfile("cap"); + // Pre-seed the counter at the bound: the last index allocates, then stop. + let max = MAX_PROOF_ADDRESS_INDEX; + std::fs::write(&path, format!("{{\"ver\":1,\"next\":{}}}", max)).unwrap(); + assert_eq!(allocate(&path).unwrap(), max); + assert!(allocate(&path).is_err(), "past the scan bound must refuse"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn corrupt_registry_restarts_at_one() { + // A corrupt counter must not brick minting; restarting at 1 can re-hand + // out an index, which at worst reuses an address (same as the old + // single-address world) — never a fund risk. + let path = tmpfile("corrupt"); + std::fs::write(&path, "not json").unwrap(); + assert_eq!(allocate(&path).unwrap(), 1); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index c83c5716..fcb3a264 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -1132,6 +1132,33 @@ impl Wallet { Ok(()) } + /// Mint a FRESH per-sale proof/slatepack address at the next unallocated + /// derivation index (index 0 stays the app's default address — nothing + /// changes for normal receives). The allocation counter persists in the + /// wallet dir and never reuses an index, so no two sales share an address; + /// the patched receive path detects which allocated address a + /// payment-proof slate is addressed to and signs the proof with the + /// matching key. Returns `(index, address)`. + pub fn mint_proof_address(&self) -> Result<(u32, String), String> { + let index = + crate::wallet::proof_addrs::allocate(&self.get_config().get_proof_addrs_path())?; + let r_inst = self.instance.as_ref().read(); + let instance = r_inst + .clone() + .ok_or_else(|| "wallet is not open".to_string())?; + let mut w_lock = instance.lock(); + let lc = w_lock.lc_provider().map_err(|e| e.to_string())?; + let w_inst = lc.wallet_inst().map_err(|e| e.to_string())?; + let k = w_inst + .keychain(self.keychain_mask().as_ref()) + .map_err(|e| e.to_string())?; + let parent_key_id = w_inst.parent_key_id(); + let sec_key = address::address_from_derivation_path(&k, &parent_key_id, index) + .map_err(|e| format!("{:?}", e))?; + let addr = SlatepackAddress::try_from(&sec_key).map_err(|e| e.to_string())?; + Ok((index, addr.to_string())) + } + /// Get unique opened wallet identifier, including current account. pub fn identifier(&self) -> String { let config = self.get_config();