Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Thumbs.db
dist/

local_checkpoints/
e2e-evidence/
logs/
/logs_*/
/db
Expand Down Expand Up @@ -75,6 +76,8 @@ AGENTS.md
ISSUES.md
TASKS.md
MEMORY.md
PLAN.md
docs/src/audit/
# Generated ts-rs bindings.
parth_core/bindings/
psy_core/bindings/
Expand Down
79 changes: 53 additions & 26 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ SEPOLIA_RPC_URL ?= https://ethereum-sepolia-rpc.publicnode.com
PSY_SKIP_BRANCH_CHECK ?= 1
PSY_SKIP_KEYSTORE ?= 1
PSY_SKIP_BUILD ?= 1
PURGE ?= 1
# PROVING_BACKEND := jtmb-poseidon-goldilocks

.PHONY: all build clean test check check-all deploy-contracts register-users query-chain-info run-all rollback-db rollback-stop rollback-resume staging-server restart restart-all shutdown clean-db run-dummy-prover config_gen_v2 generate-genesis-data generate-groth16 regen-groth16-keystore regen-bridge-agg-keystore export-solidity-verifier export-solidity-verifier-deposit export-solidity-verifier-withdrawal
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ make build
PSY_SKIP_BRANCH_CHECK=1 PSY_SKIP_KEYSTORE=1 make run-all
```

The launcher runs in the foreground and starts Realm P2P with generated local keys and a public runtime config. Stop the stack with `make shutdown`. Logs are written under `./logs`.
The launcher runs in the foreground and starts Realm P2P with generated local keys and a public runtime config. Stop the stack with `make shutdown`; this defaults to purge and deletes persisted chain state, so use `PURGE=0 make shutdown` to stop while preserving state. Logs are written under `./logs`.

To run some example transactions first run:
```bash
Expand Down
8 changes: 6 additions & 2 deletions client_prover/psy_cli/psy_user_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,11 @@ fn command_paths(cli: &Cli) -> Vec<&str> {
Commands::ProveTxTrace(args) => { push_session_paths(&mut paths, &args.session); paths.extend([args.session.rpc_config.as_str(), args.input.as_str()]); paths.extend(args.output.as_deref()); }
Commands::PrivateTransfer(args) => paths.extend([args.rpc_config.as_str(), args.output.as_str()]),
Commands::PrivateClaim(args) => { paths.push(&args.rpc_config); paths.extend(args.note_proof.as_deref()); }
Commands::DeriveNoteOwner(args) => paths.push(&args.rpc_config),
Commands::DeriveShield(args) => {
if args.private_key.is_some() {
paths.push(&args.rpc_config);
}
}
Commands::ClaimDeposit(args) => { push_wallet_paths(&mut paths, &args.wallet); paths.extend([args.rpc_config.as_str(), args.deposit_proof.as_str()]); }
Commands::Withdraw(args) => { push_wallet_paths(&mut paths, &args.wallet); paths.push(&args.rpc_config); }
Commands::Deposit(args) => { paths.push(&args.rpc_config); paths.extend(args.deposit_proof_output.as_deref()); }
Expand Down Expand Up @@ -698,7 +702,7 @@ async fn main() -> anyhow::Result<()> {
}
Commands::PrivateTransfer(args) => crate::subcommand::private_transfer::run(args).await?,
Commands::PrivateClaim(args) => crate::subcommand::private_claim::run(args).await?,
Commands::DeriveNoteOwner(args) => crate::subcommand::shield_address::run(args).await?,
Commands::DeriveShield(args) => crate::subcommand::shield_address::run(args).await?,
Commands::ClaimDeposit(args) => claim_deposit::run(args).await?,
Commands::Deposit(args) => deposit::run(args).await?,
Commands::Withdraw(args) => withdraw::run(args).await?,
Expand Down
32 changes: 26 additions & 6 deletions client_prover/psy_cli/psy_user_cli/src/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,13 @@ pub struct StatusResult {
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteOwnerResult {
pub public_key: QHashOut<F>,
pub struct ShieldAddressResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<QHashOut<F>>,
pub user_id: u64,
pub note_owner: QHashOut<F>,
pub nostr_npub: String,
pub shield_address: QHashOut<F>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nostr_npub: Option<String>,
}

/// Public headers only. The trace payload, call data, witnesses, proofs, note
Expand Down Expand Up @@ -251,7 +253,7 @@ pub enum CommandResult {
CheckpointId(CheckpointIdResult),
ClaimAmount(ClaimAmountResult),
TxStatus(StatusResult),
NoteOwner(NoteOwnerResult),
ShieldAddress(ShieldAddressResult),
TxTrace(TxTraceResult),
Proofs(ProofsResult),
Generic(GenericResult),
Expand Down Expand Up @@ -288,7 +290,7 @@ impl CommandResult {
Self::CheckpointId(v) => write_json_atomically(path, v),
Self::ClaimAmount(v) => write_json_atomically(path, v),
Self::TxStatus(v) => write_json_atomically(path, v),
Self::NoteOwner(v) => write_json_atomically(path, v),
Self::ShieldAddress(v) => write_json_atomically(path, v),
Self::TxTrace(v) => write_json_atomically(path, v),
Self::Proofs(v) => write_json_atomically(path, v),
Self::Generic(v) => write_json_atomically(path, v),
Expand Down Expand Up @@ -417,6 +419,24 @@ mod tests {
}
}

#[test]
fn shield_address_result_omits_optional_identity_on_user_id_path() {
let value = serde_json::to_value(ShieldAddressResult {
public_key: None,
user_id: 7,
shield_address: QHashOut::<F>::from_values(1, 2, 3, 4),
nostr_npub: None,
})
.unwrap();
let object = value.as_object().unwrap();
assert!(object.get("shield_address").is_some());
assert_eq!(object.get("user_id"), Some(&serde_json::json!(7)));
assert!(!object.contains_key("public_key"));
assert!(!object.contains_key("nostr_npub"));
assert!(!object.contains_key("note_owner"));
assert!(!object.contains_key("private_key"));
}

#[test]
fn typed_tree_root_result_serializes_its_payload() {
let dir = temp_dir("tree-root");
Expand Down
8 changes: 5 additions & 3 deletions client_prover/psy_cli/psy_user_cli/src/subcommand/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,11 +795,13 @@ pub struct WithdrawArgs {
}

#[derive(Clone, Args, Serialize, Deserialize)]
pub struct DeriveNoteOwnerArgs {
pub struct DeriveShieldArgs {
#[clap(env, long, default_value = "config.json")]
pub rpc_config: String,
#[clap(long, short = 'p')]
pub private_key: String,
#[clap(long, short = 'p', conflicts_with = "user_id", required_unless_present = "user_id")]
pub private_key: Option<String>,
#[clap(long, conflicts_with = "private_key", required_unless_present = "private_key")]
pub user_id: Option<u64>,
#[clap(long, default_value_t = 0)]
pub random0: u64,
#[clap(long, default_value_t = 0)]
Expand Down
4 changes: 2 additions & 2 deletions client_prover/psy_cli/psy_user_cli/src/subcommand/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ pub enum Commands {
PrivateTransfer(crate::subcommand::args::PrivateTransferArgs),
/// Claim a private note from generated proof payload.
PrivateClaim(crate::subcommand::args::PrivateClaimArgs),
/// Derive note owner hash from receiver pubkey and binding.
DeriveNoteOwner(crate::subcommand::args::DeriveNoteOwnerArgs),
/// Derive a shield address from a private key or a known user id.
DeriveShield(crate::subcommand::args::DeriveShieldArgs),
/// Claim a bridge deposit on L2. Requires the deposit proof from
/// psy-services.
ClaimDeposit(crate::subcommand::args::ClaimDepositArgs),
Expand Down
134 changes: 108 additions & 26 deletions client_prover/psy_cli/psy_user_cli/src/subcommand/shield_address.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
use std::str::FromStr;

use nostr_sdk::prelude::{Keys, ToBech32};
use plonky2::field::types::Field;
use psy_client_common::{data::qhashout::QHashOut, ups::circuits::LocalCircuitType};
use psy_client_data::config::store_config::F;
use psy_crypto::{
hash::traits::hasher::{FieldQHasher, PoseidonHasher},
hash::traits::hasher::PoseidonHasher,
shield_address::derive_shield_address,
signature::zk::wallet::SimplePsyPrivateKey,
};
use psy_prover::session::WalletSession;
use sha2::{Digest, Sha256};

use crate::{
result::{CommandResult, NoteOwnerResult},
subcommand::args::DeriveNoteOwnerArgs,
result::{CommandResult, ShieldAddressResult},
subcommand::args::DeriveShieldArgs,
};

const NOSTR_PREFIX: &[u8] = b"psy-privacy-v0-nostr";
Expand Down Expand Up @@ -62,11 +62,19 @@ fn derive_nostr_npub(private_key: &str, random0: u64, random1: u64) -> anyhow::R
Ok(keys.public_key().to_bech32()?)
}

pub async fn run(args: DeriveNoteOwnerArgs) -> anyhow::Result<CommandResult> {
let psy_config = psy_config::PsyConfigGoldilocks::from_file(&args.rpc_config)?;
pub async fn run(args: DeriveShieldArgs) -> anyhow::Result<CommandResult> {
match (&args.private_key, args.user_id) {
(Some(private_key), None) => run_from_private_key(args.rpc_config, private_key, args.random0, args.random1).await,
(None, Some(user_id)) => run_from_user_id(user_id, args.random0, args.random1),
_ => anyhow::bail!("exactly one of --private-key or --user-id is required"),
}
}

async fn run_from_private_key(rpc_config: String, private_key: &str, random0: u64, random1: u64) -> anyhow::Result<CommandResult> {
let psy_config = psy_config::PsyConfigGoldilocks::from_file(&rpc_config)?;
let rpc_config = psy_config.get_current_network()?.clone();

let receiver_sk = QHashOut::<F>::from_str(&args.private_key).map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))?;
let receiver_sk = QHashOut::<F>::from_str(private_key).map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))?;
let wallet_session = WalletSession::new(&rpc_config).await?;
let zk_sig_fingerprint = wallet_session
.circuit_info
Expand All @@ -81,25 +89,99 @@ pub async fn run(args: DeriveNoteOwnerArgs) -> anyhow::Result<CommandResult> {
.copied()
.ok_or_else(|| anyhow::anyhow!("No user id found for receiver public key"))?;

let note_owner = PoseidonHasher::q_hash_many(&[
F::from_canonical_u64(receiver_user_id),
F::from_canonical_u64(1337),
F::from_canonical_u64(args.random0),
F::from_canonical_u64(args.random1),
]);

println!("public_key: {}", receiver_public_key);
println!("user_id: {}", receiver_user_id);
println!("random0: {}", args.random0);
println!("random1: {}", args.random1);
println!("note_owner: {}", note_owner);
let nostr_npub = derive_nostr_npub(&args.private_key, args.random0, args.random1)?;
println!("nostr_npub: {}", nostr_npub);
println!("private_address: {}#{}", note_owner, nostr_npub);
Ok(CommandResult::NoteOwner(NoteOwnerResult {
public_key: receiver_public_key,
let shield_address = derive_shield_address(receiver_user_id, random0, random1);
let nostr_npub = derive_nostr_npub(private_key, random0, random1)?;
print_shield(receiver_user_id, shield_address, Some(&nostr_npub));
Ok(CommandResult::ShieldAddress(ShieldAddressResult {
public_key: Some(receiver_public_key),
user_id: receiver_user_id,
note_owner,
nostr_npub,
shield_address,
nostr_npub: Some(nostr_npub),
}))
}

fn run_from_user_id(user_id: u64, random0: u64, random1: u64) -> anyhow::Result<CommandResult> {
let shield_address = derive_shield_address(user_id, random0, random1);
print_shield(user_id, shield_address, None);
Ok(CommandResult::ShieldAddress(ShieldAddressResult {
public_key: None,
user_id,
shield_address,
nostr_npub: None,
}))
}

fn print_shield(user_id: u64, shield_address: QHashOut<F>, nostr_npub: Option<&str>) {
println!("user_id: {}", user_id);
println!("shield_address: {}", shield_address);
if let Some(nostr_npub) = nostr_npub {
println!("nostr_npub: {}", nostr_npub);
println!("private_address: {}#{}", shield_address, nostr_npub);
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::subcommand::{args::DeriveShieldArgs, Cli};
use clap::Parser;

fn parse(argv: &[&str]) -> Result<DeriveShieldArgs, clap::error::Error> {
let mut args = vec!["psy_user_cli"];
args.extend(argv);
match Cli::try_parse_from(args)?.command {
crate::subcommand::Commands::DeriveShield(args) => Ok(args),
_ => panic!("expected DeriveShield"),
}
}

#[test]
fn parse_rejects_missing_identity() {
assert!(parse(&["derive-shield", "--random0", "1", "--random1", "2"]).is_err());
}

#[test]
fn parse_rejects_both_identities() {
assert!(parse(&[
"derive-shield",
"--private-key",
"aa",
"--user-id",
"7",
"--random0",
"1",
"--random1",
"2",
])
.is_err());
}

#[test]
fn parse_accepts_user_id_without_rpc() {
let args = parse(&["derive-shield", "--user-id", "7", "--random0", "1", "--random1", "2"]).unwrap();
assert_eq!(args.user_id, Some(7));
assert!(args.private_key.is_none());
}

#[test]
fn parse_accepts_private_key() {
let args = parse(&["derive-shield", "--private-key", "aa", "--random0", "1", "--random1", "2"]).unwrap();
assert_eq!(args.private_key.as_deref(), Some("aa"));
assert!(args.user_id.is_none());
}

#[test]
fn user_id_path_matches_crypto_helper() {
let result = run_from_user_id(7, 1, 2).unwrap();
match result {
CommandResult::ShieldAddress(value) => {
assert_eq!(value.user_id, 7);
assert_eq!(value.shield_address, derive_shield_address(7, 1, 2));
assert!(value.public_key.is_none());
assert!(value.nostr_npub.is_none());
}
_ => panic!("expected ShieldAddress"),
}
}

}
46 changes: 33 additions & 13 deletions client_prover/psy_prover/src/local/native/faucet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ struct PsyFaucetService {
// Same-operator mutual exclusion is handled by `operator_locks` below.
wallet_session: Arc<WalletSession>,
claim_records: DashMap<(u64, u64), PsyFaucetClaimRecord>,
recipient_locks: DashSet<u64>,
operator_locks: DashSet<u64>,
recipient_locks: Arc<dashmap::DashSet<u64>>,
operator_locks: Arc<dashmap::DashSet<u64>>,
window_checkpoints: u64,
turnstile_secret: Option<String>,
require_turnstile: bool,
Expand Down Expand Up @@ -259,8 +259,8 @@ impl PsyFaucetService {
operators,
wallet_session: Arc::new(wallet_session),
claim_records: DashMap::new(),
recipient_locks: DashSet::new(),
operator_locks: DashSet::new(),
recipient_locks: Arc::new(DashSet::new()),
operator_locks: Arc::new(DashSet::new()),
window_checkpoints,
turnstile_secret,
require_turnstile,
Expand Down Expand Up @@ -339,24 +339,44 @@ impl PsyFaucetService {
}
Ok(())
}
}

struct DashSetEntryGuard<T: Copy + Eq + std::hash::Hash>(Arc<dashmap::DashSet<T>>, T);

impl<T: Copy + Eq + std::hash::Hash> Drop for DashSetEntryGuard<T> {
fn drop(&mut self) {
self.0.remove(&self.1);
}
}

impl PsyFaucetService {
// Turnstile-gated entry, used by the public web frontend and the hosted
// wallet verification page.
async fn claim(&self, input: PsyFaucetClaimRequest) -> Result<PsyFaucetClaimResponse, ErrorObjectOwned> {
async fn claim(self: &Arc<Self>, input: PsyFaucetClaimRequest) -> Result<PsyFaucetClaimResponse, ErrorObjectOwned> {
self.verify_turnstile(input.turnstile_token.as_deref(), input.turnstile_state.as_deref())
.await?;
self.claim_for_recipient(input).await
}

async fn claim_for_recipient(&self, input: PsyFaucetClaimRequest) -> Result<PsyFaucetClaimResponse, ErrorObjectOwned> {
async fn claim_for_recipient(self: &Arc<Self>, input: PsyFaucetClaimRequest) -> Result<PsyFaucetClaimResponse, ErrorObjectOwned> {
let recipient_user_id = input.recipient_user_id;
if self.recipient_locks.insert(recipient_user_id) {
let result = self.claim_locked(input).await;
self.recipient_locks.remove(&recipient_user_id);
result
} else {
Err(rpc_error("faucet claim already in progress for this recipient"))
if !self.recipient_locks.insert(recipient_user_id) {
return Err(rpc_error("faucet claim already in progress for this recipient"));
}
// The claim runs in a detached task that owns the lock guards: a client
// disconnect drops this RPC future but never the locks, so the
// recipient/operator mutex windows span the whole proving-and-submit
// work exactly once.
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let worker = Arc::clone(self);
tokio::spawn(async move {
let _recipient_guard = DashSetEntryGuard(Arc::clone(&worker.recipient_locks), recipient_user_id);
let result = worker.claim_locked(input).await;
let _ = result_tx.send(result);
});
result_rx
.await
.map_err(|_| rpc_error("faucet claim task terminated"))?
}

async fn claim_locked(&self, input: PsyFaucetClaimRequest) -> Result<PsyFaucetClaimResponse, ErrorObjectOwned> {
Expand Down Expand Up @@ -400,9 +420,9 @@ impl PsyFaucetService {
continue;
}
tried_operator = true;
let _operator_guard = DashSetEntryGuard(Arc::clone(&self.operator_locks), operator.user_id);

let submit_result = self.submit_with_operator(operator, input.recipient_user_id, amount).await;
self.operator_locks.remove(&operator.user_id);

match submit_result {
Ok(tx_hash) => {
Expand Down
Loading