Skip to content
Merged
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
20 changes: 15 additions & 5 deletions crates/buzz/crates/maxplayer-private-protocol/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,25 @@ impl HostPolicy {
Ok(())
}
pub fn mint(&self, mint: &str) -> Result<()> {
self.well_formed_mint(mint)?;
if !self.accepted_mints.iter().any(|v| v == mint) {
return Err(Error("unapproved mint"));
}
Ok(())
}

/// Signed claim and receipt mints need not be configured by the checker.
/// Settlement still binds the destination to the signed seller claim.
pub fn well_formed_mint(&self, mint: &str) -> Result<()> {
// Signed wire values are canonical: unlike configured wallet URLs, no
// trailing slash is stripped here. Repository URLs remain HTTPS-only.
if let Some(npub) = mint.strip_prefix("nostr://") {
super::mint::decode_npub(npub).ok_or(Error("invalid Nostr mint URL"))?;
} else {
secure_url(mint)?;
}
if mint.len() > 2048 || !self.accepted_mints.iter().any(|v| v == mint) {
return Err(Error("unapproved mint"));
if mint.len() > 2048 {
return Err(Error("mint URL too long"));
}
Ok(())
}
Expand Down Expand Up @@ -417,7 +427,7 @@ pub fn validate_private(event: &Event, host: &HostPolicy) -> Result<Tags> {
"job-class" => member(v, &["contribution"])?,
"metadata_trust" => member(v, &["seller-claimed"])?,
"repo" => host.repo(v)?,
"mint" => host.mint(v)?,
"mint" => host.well_formed_mint(v)?,
"branch" => require_hex(
v.strip_prefix("refs/heads/delivery/")
.ok_or(Error("invalid delivery ref"))?,
Expand Down Expand Up @@ -633,7 +643,7 @@ mod mint_tests {
}

#[test]
fn private_receipt_validates_realized_nostr_mint_membership() {
fn private_receipt_validates_realized_nostr_mint_well_formedness() {
use nostr::prelude::{EventBuilder, Keys, Kind, Tag};
let mint = format!("nostr://{NPUB}");
let keys = Keys::parse(&format!("{:064x}", 1)).unwrap();
Expand All @@ -660,7 +670,7 @@ mod mint_tests {
.sign_with_keys(&keys)
.unwrap();
validate_private(&event, &policy(&mint)).unwrap();
assert!(validate_private(&event, &policy("https://mint.example")).is_err());
validate_private(&event, &policy("https://mint.example")).unwrap();
}

#[test]
Expand Down
65 changes: 63 additions & 2 deletions crates/maxplayer-core/src/authorize_pay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1150,8 +1150,18 @@ fn receipt_preimage_bound(
let mut computed = receipt_preimage_for(key, buyer, seller, kind);
if let Some(private) = private {
computed.protocol = crate::receipt::ReceiptProtocol::V2;
if computed != private.preimage || key.result_id.as_str() != private.evidence.result.id.to_hex() {
return Err(EffectError::new("payment key differs from immutable private result"));
if !crate::private_content::public_v2::is_public(&private.evidence.offer) {
private
.evidence
.validate_realized_mint(&key.mint.to_string(), &private.policy)
.map_err(|e| EffectError::new(e.to_string()))?;
}
if computed != private.preimage
|| key.result_id.as_str() != private.evidence.result.id.to_hex()
{
return Err(EffectError::new(
"payment key differs from immutable private result",
));
}
}
Ok(computed)
Expand Down Expand Up @@ -2002,6 +2012,57 @@ mod tests {
);
}

#[test]
fn credits_first_receipt_destination_guard_runs_before_spend() {
let (evidence, request, buyer, policy) =
crate::private_content::evidence::inline_fixture_with_payment(true, true);
let verified = evidence
.validate_request(&request, &buyer.public_key().to_hex(), &policy)
.unwrap();
let private = PrivateReceipt {
preimage: verified.preimage,
evidence,
policy,
};
let dir = tempfile::tempdir().unwrap();
let mut home = home::bootstrap(dir.path()).unwrap();
home.config.allow_real_mints = true;
let derive = |mints: &[String]| {
derive_payment(
&home,
&request.job_id,
&request.result_id,
&request.delivery_integrity_hash,
&request.job_hash,
&request.seller_pubkey,
request.amount_sats,
mints,
Some("https://funding.example"),
request.creq_hash.clone(),
)
.unwrap()
};
let good = derive(&request.accepted_mints);
receipt_preimage_bound(
&good.key,
&buyer.public_key().to_hex(),
&request.seller_pubkey,
DeliveryKind::Inline,
Some(&private),
)
.unwrap();
let bad = derive(&["https://outside.example".into()]);
let err = receipt_preimage_bound(
&bad.key,
&buyer.public_key().to_hex(),
&request.seller_pubkey,
DeliveryKind::Inline,
Some(&private),
)
.unwrap_err();
assert!(err.to_string().contains("outside signed claim"), "{err}");
}

// Finding CC (load-bearing): the pay-path attempt id is derived from the SEALED realized-mint
// SELECTION frozen in the accept-bind — NOT the live config default — so a config-default change
// BETWEEN attempts (e.g. after a receipt-publish failure, before the buyer retries) cannot shift
Expand Down
Loading
Loading