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
22 changes: 16 additions & 6 deletions backend/src/models/mission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ impl Mission {
67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 => DoKind::Knowledge,
77 | 78 => DoKind::Knowledge,
79 | 80 | 81 | 82 | 83 => DoKind::Knowledge,
84 | 85 | 86 => DoKind::Knowledge,
85 | 86 => DoKind::Knowledge,
87 | 88 | 89 | 90 => DoKind::Knowledge,
91 | 93 | 94 | 95 | 96 => DoKind::Knowledge,
97 | 98 | 99 => DoKind::Knowledge,
Expand All @@ -333,7 +333,7 @@ impl Mission {
// your own words (103). Split out of the Knowledge run above so
// verify_proof can require a minimum length instead of letting
// a one-character reflection clear the mission (issue #81).
102 | 103 => DoKind::PasteValue,
84 | 102 | 103 => DoKind::PasteValue,
106 | 107 | 108 | 109 | 110 => DoKind::Knowledge,

// Action missions:
Expand Down Expand Up @@ -428,6 +428,15 @@ mod tests {
}
}

/// Mission 84 decodes a bundled Cashu sample through the paste-value
/// input. Its neighbours remain reading-only lessons.
#[test]
fn mission_84_is_not_shadowed_by_knowledge_arms() {
assert_eq!(Mission::do_kind(84), DoKind::PasteValue);
assert_eq!(Mission::do_kind(85), DoKind::Knowledge);
assert_eq!(Mission::do_kind(86), DoKind::Knowledge);
}

/// The bar itself: every flight path carries at least one mission with a
/// real action, except Money Basics, which is deliberately exempt
/// because it is the conceptual opening path.
Expand Down Expand Up @@ -459,10 +468,11 @@ pub enum DoKind {
/// clear a minimum length so it isn't the same "any non-empty string
/// passes" bar as `Knowledge`. See `verify_proof`'s `PasteValue` arm.
///
/// Covers missions 102/103 only. Mission 106 also renders a paste-value
/// input on the frontend, but intentionally stays `Knowledge` here: it's a
/// numeric naira-quote input, not a reflection, so the minimum-length floor
/// doesn't apply.
/// Covers mission 84's exact decoded Cashu facts and missions 102/103's
/// reflections. Mission 106 also renders a paste-value input on the
/// frontend, but intentionally stays `Knowledge` here: it is a numeric
/// naira-quote input, not a reflection, so the minimum-length floor does
/// not apply.
PasteValue,
NostrIdentity,
Invoice,
Expand Down
29 changes: 28 additions & 1 deletion backend/src/routes/missions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,32 @@ async fn complete_mission(
}))
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CashuDecodeProof {
format: String,
mint: String,
amount_sats: u64,
proof_count: usize,
}

fn verify_cashu_decode_proof(proof: &str) -> Result<(), AppError> {
let decoded: CashuDecodeProof = serde_json::from_str(proof)
.map_err(|_| AppError::BadRequest("mission 84 requires the decoded sample facts".into()))?;
if decoded.format != "cashuA-v3"
|| decoded.mint != "https://8333.space:3338"
|| decoded.amount_sats != 10
|| decoded.proof_count != 2
{
return Err(AppError::BadRequest(
"mission 84 requires the official cashuA V3 sample facts".into(),
));
}
Ok(())
}

/// Minimum character count for a `DoKind::PasteValue` reflection (missions
/// 102/103). Not tied to any protocol fact it's a judgment call about how
/// 102/103). Not tied to any protocol fact - it is a judgment call about how
/// much text rules out a "no" or a single pasted character while still
/// welcoming a short-but-real answer (a bare docs link, a one-line
/// restatement). Deliberately well under the frontend's per-mission
Expand Down Expand Up @@ -221,6 +245,9 @@ async fn verify_proof(
// one-character answer can't pass it off as substance. The message
// is written as an invitation to say more, not a rejection.
DoKind::PasteValue => {
if mission == 84 {
return verify_cashu_decode_proof(proof);
}
if proof.chars().count() < MIN_PASTE_VALUE_LEN {
return Err(AppError::BadRequest(format!(
"say a little more: at least {MIN_PASTE_VALUE_LEN} characters, so there's something real to reflect on"
Expand Down
54 changes: 54 additions & 0 deletions backend/tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,60 @@ fn paste_value_missions_require_more_than_a_word() {
assert_eq!(r.status(), 200, "{}", r.text().unwrap());
}

#[test]
fn mission_84_decode_requires_canonical_proof_and_advances() {
let h = Harness::start();
let s = create_session(&h.base, "cashu-decode-test");
let j = join_session(&h.base, "mina", &s.id);

for m in [31u8, 32] {
let r = complete(&h.base, &j.auth_token, m, "acknowledged");
assert_eq!(r.status(), 200, "mission {m} should succeed");
}

let minted: Value = client()
.post(format!("{}/api/ecash/mint", h.base))
.header("authorization", format!("Bearer {}", j.auth_token))
.json(&json!({ "amount_sats": 50 }))
.send()
.unwrap()
.json()
.unwrap();
let token = minted["token"].as_str().unwrap();
assert_eq!(complete(&h.base, &j.auth_token, 33, token).status(), 200);

let redeemed = client()
.post(format!("{}/api/ecash/redeem", h.base))
.header("authorization", format!("Bearer {}", j.auth_token))
.json(&json!({ "token": token }))
.send()
.unwrap();
assert_eq!(redeemed.status(), 200);
assert_eq!(complete(&h.base, &j.auth_token, 34, token).status(), 200);

let invalid_proofs = [
"acknowledged",
r#"{"format":"cashuA-v3","mint":"https://8333.space:3338","amount_sats":10}"#,
r#"{"format":"cashuA-v3","mint":"https://8333.space:3338","amount_sats":10,"proof_count":2,"extra":true}"#,
r#"{"format":"cashuB-v4","mint":"https://8333.space:3338","amount_sats":10,"proof_count":2}"#,
r#"{"format":"cashuA-v3","mint":"https://example.com","amount_sats":10,"proof_count":2}"#,
r#"{"format":"cashuA-v3","mint":"https://8333.space:3338","amount_sats":11,"proof_count":2}"#,
r#"{"format":"cashuA-v3","mint":"https://8333.space:3338","amount_sats":10,"proof_count":1}"#,
];
for proof in invalid_proofs {
let r = complete(&h.base, &j.auth_token, 84, proof);
assert_eq!(r.status(), 400, "mission 84 accepted invalid proof: {proof}");
}

let canonical =
r#"{"format":"cashuA-v3","mint":"https://8333.space:3338","amount_sats":10,"proof_count":2}"#;
let r = complete(&h.base, &j.auth_token, 84, canonical);
assert_eq!(r.status(), 200, "{}", r.text().unwrap());
let v: Value = r.json().unwrap();
assert_eq!(v["next_mission"], 55);
assert_eq!(v["participant"]["current_per_tree"]["ecash"], 55);
}

#[test]
fn proof_archive_lists_completions_in_order() {
let h = Harness::start();
Expand Down
Loading
Loading