1
0
forked from GRIN/grim

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:
2ro
2026-07-06 01:56:06 -04:00
parent c0cdeb6d6b
commit ea07fd2081
4 changed files with 159 additions and 0 deletions
+8
View File
@@ -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());
+2
View File
@@ -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;
+122
View File
@@ -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(&reg).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);
}
}
+27
View File
@@ -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();