goblin: per-sale proof-address allocator + mint API (Build 149)
App side of the index-0 unpinning. Wallet::mint_proof_address allocates the next fresh derivation index from a persisted, never-reusing counter (src/wallet/proof_addrs.rs; proof_addrs.json in the wallet dir — an index carries no secret, the keys live in the wallet seed) and derives its slatepack/proof address through the wallet's existing indexed derivation (address_from_derivation_path), exactly as the app's default index-0 address is derived. Index 0 stays the default app address; nothing changes for normal receives. Allocation starts at 1, is monotonic and persisted before use (a crash burns an index, never reuses one), and is capped at MAX_PROOF_ADDRESS_INDEX = 1023 — the receive-side scan bound of the matching grin-wallet submodule patch (kept as a local constant so this crate also builds against the unpatched upstream submodule; the wallet-side patch, which detects the addressed index on receive and signs the payment proof with the matching key, lives in the wallet submodule on a local branch pending the owner's decision on where that patch is pinned). Tests: allocation monotonic + persistent from 1, refuses past the scan bound, corrupt counter degrades to address reuse (the old single-address world), never a fund risk.
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u32, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user