diff --git a/backend/src/models/mission.rs b/backend/src/models/mission.rs index c4a3206..9445043 100644 --- a/backend/src/models/mission.rs +++ b/backend/src/models/mission.rs @@ -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, @@ -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: @@ -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. @@ -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, diff --git a/backend/src/routes/missions.rs b/backend/src/routes/missions.rs index 77f17ec..8f9fdf3 100644 --- a/backend/src/routes/missions.rs +++ b/backend/src/routes/missions.rs @@ -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 @@ -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" diff --git a/backend/tests/api.rs b/backend/tests/api.rs index 23aa96b..ab1522e 100644 --- a/backend/tests/api.rs +++ b/backend/tests/api.rs @@ -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(); diff --git a/frontend/e2e/ecash-decode.test.mjs b/frontend/e2e/ecash-decode.test.mjs new file mode 100644 index 0000000..5cb8687 --- /dev/null +++ b/frontend/e2e/ecash-decode.test.mjs @@ -0,0 +1,294 @@ +/** + * Smoke test: mission 84 decodes the official Cashu NUT-00 V3 sample in + * the browser, displays its facts, and sends only canonical facts as proof. + * + * node e2e/ecash-decode.test.mjs + */ +import { + apiPost, + enterTree, + launch, + makeReporter, + openApp, + passLearnAndQuiz, + sleep, +} from './_lib.mjs' + +const report = makeReporter('ecash-decode') + +const SAMPLE = + 'cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9' +const CANONICAL_PROOF = { + format: 'cashuA-v3', + mint: 'https://8333.space:3338', + amount_sats: 10, + proof_count: 2, +} + +function cashuA(value) { + return 'cashuA' + Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function mutateSample(mutator) { + const sample = JSON.parse(Buffer.from(SAMPLE.slice('cashuA'.length), 'base64url').toString('utf8')) + mutator(sample) + return cashuA(sample) +} + +const session = await apiPost('/sessions', { name: 'Cashu Decode E2E' }) +const joined = await apiPost('/participants', { + name: 'E2E', + session_id: session.session.id, +}) +const creds = { + sid: session.session.id, + pid: joined.participant.id, + token: joined.auth_token, +} + +for (const mission of [31, 32]) { + await apiPost('/missions/complete', { mission, proof: 'acknowledged' }, creds.token) +} +const minted = await apiPost('/ecash/mint', { amount_sats: 50 }, creds.token) +await apiPost('/missions/complete', { mission: 33, proof: minted.token }, creds.token) +await apiPost('/ecash/redeem', { token: minted.token }, creds.token) +await apiPost('/missions/complete', { mission: 34, proof: minted.token }, creds.token) + +const browser = await launch() +try { + const page = await openApp(browser, creds) + const completionProofs = [] + const decodeRequests = [] + let decoding = false + let holdMissionAt84 = false + await page.route('**/missions/complete', async (route) => { + if (holdMissionAt84) { + await route.abort() + return + } + await route.continue() + }) + page.on('request', (request) => { + if (!decoding) return + decodeRequests.push(request.url()) + if (request.url().includes('/missions/complete')) { + const body = JSON.parse(request.postData() ?? '{}') + completionProofs.push(body.proof) + } + }) + + await enterTree(page, 'eCash') + await sleep(500) + + report.assert( + (await page.getByText(/^Offline sample$/i).count()) > 0, + 'mission 84 is marked as an offline sample', + ) + report.assert( + (await page.locator('code', { hasText: SAMPLE }).count()) > 0, + 'the official Cashu V3 sample is selectable lesson code', + ) + + await passLearnAndQuiz(page, 'Whoever currently holds the token', report) + + const field = page.getByLabel('Cashu token to decode', { exact: true }) + const hasField = (await field.count()) > 0 + report.assert(hasField, 'the mission-specific Cashu token field is presented') + if (hasField) await field.fill(SAMPLE) + + const action = page.getByRole('button', { name: /Decode sample token/i }) + const hasAction = (await action.count()) > 0 + report.assert(hasAction, 'the decoder action is offered') + if (hasAction) { + decoding = true + const invalidSamples = [ + [ + 'explicit non-sat unit', + mutateSample((sample) => { + sample.unit = 'usd' + }), + ], + [ + 'proof missing id', + mutateSample((sample) => { + delete sample.token[0].proofs[0].id + }), + ], + [ + 'proof missing secret', + mutateSample((sample) => { + delete sample.token[0].proofs[0].secret + }), + ], + [ + 'proof missing C', + mutateSample((sample) => { + delete sample.token[0].proofs[0].C + }), + ], + [ + 'proof id is not hexadecimal', + mutateSample((sample) => { + sample.token[0].proofs[0].id = 'not hex' + }), + ], + [ + 'proof secret is not a string', + mutateSample((sample) => { + sample.token[0].proofs[0].secret = 42 + }), + ], + [ + 'proof C is not hexadecimal', + mutateSample((sample) => { + sample.token[0].proofs[0].C = 'not hex' + }), + ], + [ + 'later proof has degenerate metadata', + mutateSample((sample) => { + Object.assign(sample.token[0].proofs[1], { + id: '00', + secret: '', + C: '00', + }) + }), + ], + [ + 'later proof has an empty secret', + mutateSample((sample) => { + sample.token[0].proofs[1].secret = '' + }), + ], + [ + 'later proof has an undersized V1 keyset id', + mutateSample((sample) => { + sample.token[0].proofs[1].id = '00' + }), + ], + [ + 'later proof C is not a compressed SEC1 point', + mutateSample((sample) => { + sample.token[0].proofs[1].C = '04' + '00'.repeat(32) + }), + ], + [ + 'later proof C is not on secp256k1', + mutateSample((sample) => { + sample.token[0].proofs[1].C = '02' + 'ff'.repeat(32) + }), + ], + ['malformed base64url', 'cashuA%%%'], + ['invalid UTF-8', 'cashuA_w'], + ['invalid JSON', 'cashuA' + Buffer.from('{').toString('base64url')], + ['wrong prefix', 'tokenA' + SAMPLE.slice(6)], + ['empty token list', cashuA({ token: [] })], + [ + 'more than one mint entry', + cashuA({ + token: [ + { mint: CANONICAL_PROOF.mint, proofs: [{ amount: 10 }] }, + { mint: CANONICAL_PROOF.mint, proofs: [{ amount: 10 }] }, + ], + }), + ], + ['empty mint', cashuA({ token: [{ mint: '', proofs: [{ amount: 10 }] }] })], + ['missing proofs', cashuA({ token: [{ mint: CANONICAL_PROOF.mint }] })], + ['empty proofs', cashuA({ token: [{ mint: CANONICAL_PROOF.mint, proofs: [] }] })], + [ + 'non-integer amount', + cashuA({ token: [{ mint: CANONICAL_PROOF.mint, proofs: [{ amount: 1.5 }] }] }), + ], + [ + 'non-positive amount', + cashuA({ token: [{ mint: CANONICAL_PROOF.mint, proofs: [{ amount: 0 }] }] }), + ], + [ + 'unexpected mint', + cashuA({ token: [{ mint: 'https://example.com', proofs: [{ amount: 2 }, { amount: 8 }] }] }), + ], + [ + 'unexpected amount', + cashuA({ token: [{ mint: CANONICAL_PROOF.mint, proofs: [{ amount: 2 }, { amount: 9 }] }] }), + ], + [ + 'unexpected proof count', + cashuA({ token: [{ mint: CANONICAL_PROOF.mint, proofs: [{ amount: 10 }] }] }), + ], + ] + for (const [name, token] of invalidSamples) { + const completionCount = completionProofs.length + await field.fill(token) + holdMissionAt84 = true + await action.first().click({ force: true }) + await sleep(100) + holdMissionAt84 = false + report.assert( + (await page.getByRole('alert').count()) > 0, + `${name} is rejected before completion`, + ) + report.assert( + completionProofs.length === completionCount, + `${name} is rejected without a completion request`, + ) + } + completionProofs.length = 0 + decodeRequests.length = 0 + + const omittedUnitSample = mutateSample((sample) => { + delete sample.unit + }) + await field.fill(omittedUnitSample) + holdMissionAt84 = true + await action.first().click({ force: true }) + await sleep(100) + holdMissionAt84 = false + report.assert( + completionProofs.length === 1 && + completionProofs[0] === JSON.stringify(CANONICAL_PROOF), + 'an omitted V3 unit is accepted as implied sats', + ) + completionProofs.length = 0 + decodeRequests.length = 0 + + await field.fill('cashuBdeadbeef') + await action.first().click({ force: true }) + await sleep(100) + const v4Error = await page.getByRole('alert').first().innerText() + report.assert( + /cashuB/i.test(v4Error) && /V4/i.test(v4Error) && /CBOR/i.test(v4Error), + 'cashuB receives a focused V4 CBOR explanation', + ) + + await field.fill(SAMPLE) + await action.first().click({ force: true }) + await sleep(1500) + decoding = false + } + + const body = await page.locator('#main-content, main, body').first().innerText() + report.assert(body.includes('https://8333.space:3338'), 'decoded mint URL is shown') + report.assert(/10 sats/i.test(body), 'decoded total of 10 sats is shown') + report.assert(/2 proofs/i.test(body), 'decoded proof count of 2 is shown') + report.assert( + completionProofs.length === 1 && + completionProofs[0] === JSON.stringify(CANONICAL_PROOF), + 'only canonical decoded facts are submitted as proof', + ) + report.assert( + completionProofs.every((proof) => !proof.includes(SAMPLE)), + 'the raw token stays in the browser', + ) + report.assert( + decodeRequests.length === 1 && decodeRequests[0].includes('/missions/complete'), + 'decoding makes no network request beyond mission completion', + ) + report.assert( + (await page.getByRole('button', { name: /Next: Blind signatures, plainly/i }).count()) > 0, + 'successful completion advances to mission 55', + ) +} finally { + await browser.close() +} + +process.exit(report.finish() ? 0 : 1) diff --git a/frontend/e2e/run.mjs b/frontend/e2e/run.mjs index 7d64a0c..accd12c 100644 --- a/frontend/e2e/run.mjs +++ b/frontend/e2e/run.mjs @@ -19,6 +19,7 @@ const suite = [ ['publish-confirm.test.mjs'], ['badge-share.test.mjs'], ['passphrase-fork.test.mjs'], + ['ecash-decode.test.mjs'], ['mission-prose.test.mjs'], ['solo-rank.test.mjs'], ] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4b23712..8df52d9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.1.0", "@fontsource-variable/jetbrains-mono": "^5.1.0", + "@noble/curves": "^1.9.7", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "@tanstack/react-query": "^5.28.0", diff --git a/frontend/package.json b/frontend/package.json index 346ea45..069137e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.1.0", "@fontsource-variable/jetbrains-mono": "^5.1.0", + "@noble/curves": "^1.9.7", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "@tanstack/react-query": "^5.28.0", diff --git a/frontend/src/lib/cashu.ts b/frontend/src/lib/cashu.ts new file mode 100644 index 0000000..cedea8c --- /dev/null +++ b/frontend/src/lib/cashu.ts @@ -0,0 +1,156 @@ +import { secp256k1 } from '@noble/curves/secp256k1' + +export const CASHU_V3_SAMPLE = + 'cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9' + +export interface CashuV3Facts { + mint: string + amountSats: number + proofCount: number +} + +const OFFICIAL_FACTS: CashuV3Facts = { + mint: 'https://8333.space:3338', + amountSats: 10, + proofCount: 2, +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isV1KeysetId(value: unknown): value is string { + return typeof value === 'string' && /^00[0-9a-f]{14}$/i.test(value) +} + +function isNonEmptySecret(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isCompressedSecp256k1Point(value: unknown): value is string { + if (typeof value !== 'string' || !/^(02|03)[0-9a-f]{64}$/i.test(value)) return false + try { + secp256k1.ProjectivePoint.fromHex(value) + return true + } catch { + return false + } +} + +function decodeBase64Url(payload: string): Uint8Array { + if (!payload || !/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) { + throw new Error('The cashuA payload is not valid base64url.') + } + + const standard = payload.replace(/-/g, '+').replace(/_/g, '/') + const padded = standard + '='.repeat((4 - (standard.length % 4)) % 4) + let binary: string + try { + binary = atob(padded) + } catch { + throw new Error('The cashuA payload is not valid base64url.') + } + + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + let encoded = '' + for (const byte of bytes) encoded += String.fromCharCode(byte) + const canonical = btoa(encoded) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + if (canonical !== payload) { + throw new Error('The cashuA payload is not valid base64url.') + } + return bytes +} + +export function decodeCashuV3Sample(input: string): CashuV3Facts { + const token = input.trim() + if (token.startsWith('cashuB')) { + throw new Error( + 'cashuB is the current V4 CBOR format. This exercise uses the bundled cashuA V3 JSON sample.', + ) + } + if (!token.startsWith('cashuA')) { + throw new Error('Paste a cashuA token. This exercise uses the bundled V3 JSON sample.') + } + + const bytes = decodeBase64Url(token.slice('cashuA'.length)) + let json: string + try { + json = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + throw new Error('The cashuA payload is not valid UTF-8 text.') + } + + let decoded: unknown + try { + decoded = JSON.parse(json) + } catch { + throw new Error('The cashuA payload is not valid JSON.') + } + if (!isRecord(decoded) || !Array.isArray(decoded.token) || decoded.token.length !== 1) { + throw new Error('The sample must contain exactly one mint entry.') + } + if (decoded.unit !== undefined && decoded.unit !== 'sat') { + throw new Error('This exercise can only report a Cashu token denominated in sats.') + } + + const entry = decoded.token[0] + if (!isRecord(entry) || typeof entry.mint !== 'string' || !entry.mint.trim()) { + throw new Error('The sample mint entry must include a mint URL.') + } + if (!Array.isArray(entry.proofs) || entry.proofs.length === 0) { + throw new Error('The sample mint entry must include at least one proof.') + } + + let amountSats = 0 + for (const proof of entry.proofs) { + if ( + !isRecord(proof) || + typeof proof.amount !== 'number' || + !Number.isSafeInteger(proof.amount) || + proof.amount <= 0 + ) { + throw new Error('Every proof amount must be a positive integer.') + } + if ( + !isV1KeysetId(proof.id) || + !isNonEmptySecret(proof.secret) || + !isCompressedSecp256k1Point(proof.C) + ) { + throw new Error( + 'Every V3 proof must include a valid V1 keyset id, a non-empty secret, and a compressed secp256k1 point C.', + ) + } + amountSats += proof.amount + if (!Number.isSafeInteger(amountSats)) { + throw new Error('The proof total is too large to decode safely.') + } + } + + const facts = { + mint: entry.mint, + amountSats, + proofCount: entry.proofs.length, + } + if ( + facts.mint !== OFFICIAL_FACTS.mint || + facts.amountSats !== OFFICIAL_FACTS.amountSats || + facts.proofCount !== OFFICIAL_FACTS.proofCount + ) { + throw new Error( + 'Use the bundled sample exactly as shown. It decodes to https://8333.space:3338, 10 sats, and 2 proofs.', + ) + } + return facts +} + +export function cashuV3CompletionProof(facts: CashuV3Facts): string { + return JSON.stringify({ + format: 'cashuA-v3', + mint: facts.mint, + amount_sats: facts.amountSats, + proof_count: facts.proofCount, + }) +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c235783..561e24e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,3 +1,5 @@ +import { CASHU_V3_SAMPLE } from './cashu' + // ─── Backend-shape types ───────────────────────────────────────────────────── export interface Participant { id: string @@ -225,6 +227,8 @@ export interface MissionDef { helper: string /** For text inputs: placeholder. */ placeholder?: string + /** Optional mission-specific label for the primary text input. */ + inputLabel?: string /** For text inputs: max length. */ maxLength?: number /** @@ -2296,18 +2300,21 @@ export const MISSIONS: MissionDef[] = [ ], }, }), - knowledge({ + { id: 84, emoji: '🎟️', topic: 'eCash', tech: 'ecash', name: 'What a token actually looks like', - tagline: 'A Cashu token is a base64 string. Anyone holding it is the owner.', + tagline: 'Decode a real sample and inspect its mint, value, and proofs', + simulated: false, learn: { heading: 'Bearer notes as text', body: - "A Cashu eCash token is a compact base64-URL string, prefixed with `cashuA` (or the newer versioned prefix). Decode it and you get JSON: which mint issued it, a list of proofs (id, amount, unblinded signature), and optionally a memo.\n\nWhoever holds the token *is* the owner. Give it to your friend by any channel, email, SMS, a QR code, a scribbled note, and it is theirs. They redeem it against the mint; you can't spend it after that because the mint will refuse the second redemption.\n\nThis is what people mean when they call eCash \"digital cash\". It's a bearer instrument in the strictest sense: possession = ownership. No account, no signature required to send, no reversal. Which is exactly the good and the bad of physical cash, transplanted into a text string.", - tip: 'A Cashu token is a slip of paper. Whoever holds the slip has the sats. Do not share by group text.', + "Cashu has two token formats you will see. `cashuA` is legacy V3: base64url-encoded JSON. `cashuB` is current V4: base64url-encoded CBOR. This exercise uses JSON so your browser can reveal the structure without a package or a network request.\n\nThe official NUT-00 V3 sample is below. Select and copy the whole token:\n\n`" + + CASHU_V3_SAMPLE + + "`\n\nDecode it and you will find the mint URL and a list of proofs. Each proof carries an amount, keyset id, secret, and unblinded signature. Whoever holds a valid token can redeem it, so real tokens must be treated like cash.", + tip: 'This is a public sample worth 10 test sats. Your own Cashu tokens are bearer money, so keep them private.', }, quiz: { question: 'What does "bearer instrument" mean for a Cashu token?', @@ -2317,7 +2324,15 @@ export const MISSIONS: MissionDef[] = [ { text: 'It requires a signature to transfer', correct: false, why: 'Nope, hand over the text and the transfer is done.' }, ], }, - }), + do: { + kind: 'paste-value', + actionLabel: 'Decode sample token', + helper: 'Paste the official sample from the lesson. It is decoded locally, and the raw token stays in this browser.', + inputLabel: 'Cashu token to decode', + placeholder: 'cashuA...', + maxLength: 800, + }, + }, knowledge({ id: 85, emoji: '🗂️', diff --git a/frontend/src/views/LearnerView.tsx b/frontend/src/views/LearnerView.tsx index ba99541..7773bfc 100644 --- a/frontend/src/views/LearnerView.tsx +++ b/frontend/src/views/LearnerView.tsx @@ -9,6 +9,7 @@ import { type CSSProperties, } from 'react' import { api, ApiError } from '../lib/api' +import { cashuV3CompletionProof, decodeCashuV3Sample } from '../lib/cashu' import { rich } from '../lib/rich' import { GOALS, getSavedGoal, saveGoal, type Goal } from '../lib/goals' import { useIsTechReal } from '../lib/runtime' @@ -753,6 +754,20 @@ export default function LearnerView({ participantId }: { participantId: string } setLoading(false) return } + if (mission.id === 84) { + const facts = decodeCashuV3Sample(doInput) + proof = cashuV3CompletionProof(facts) + outcome = { + summary: 'Official sample decoded locally.', + details: [ + { label: 'mint', value: facts.mint }, + { label: 'amount', value: `${facts.amountSats} sats` }, + { label: 'proofs', value: `${facts.proofCount} proofs` }, + ], + simulated: false, + } + break + } proof = doInput.trim() outcome = { summary: 'Saved. This is your raw material for the final mission.', @@ -1677,7 +1692,9 @@ function BadgesStrip({ function MissionHeader({ mission }: { mission: MissionDef }) { const techReal = useIsTechReal(mission.tech) const statusChip = - mission.tech === 'lightning' + mission.id === 84 + ? { label: 'Offline sample', tone: 'green' as const } + : mission.tech === 'lightning' ? techReal ? { label: 'Testnet', tone: 'green' as const } : { label: 'Simulated', tone: 'neutral' as const } @@ -1736,6 +1753,8 @@ function MissionHeader({ mission }: { mission: MissionDef }) { title={ statusChip.label === 'Simulated' ? "Action is simulated, no real value moves." + : statusChip.label === 'Offline sample' + ? 'A bundled public sample decoded only in this browser.' : statusChip.label === 'Testnet' ? 'Real Lightning, signet network, no mainnet sats.' : statusChip.label === 'Testmint' @@ -2302,7 +2321,7 @@ function DoPanel({ {!outcome && ui.primary && (
{ui.primary.type === 'textarea' ? ( <>