From f9f21c0272cb4b23eb9131df25afe39b636cff99 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 12:59:14 -0700 Subject: [PATCH] Offer a three-dollar Tezos braincell pack --- system/backend/tezos-credits.mjs | 21 ++++++++++++++----- system/public/braincells/checkout.css | 1 + system/public/braincells/checkout.mjs | 24 ++++++++++++++++++---- system/public/braincells/index.html | 5 +++-- system/tests/tezos-checkout.browser.mjs | 27 ++++++++++++++++--------- system/tests/tezos-credits.test.mjs | 22 ++++++++++++++++++-- tezos/README.md | 5 +++-- 7 files changed, 81 insertions(+), 24 deletions(-) diff --git a/system/backend/tezos-credits.mjs b/system/backend/tezos-credits.mjs index caaf832243..72669337ee 100644 --- a/system/backend/tezos-credits.mjs +++ b/system/backend/tezos-credits.mjs @@ -5,10 +5,14 @@ import { CREDIT_PACK, fulfillGrant } from './easel-paid-credits.mjs'; export const TEZOS_TREASURY = 'tz1gkf8EexComFBJvjtT1zdsisdah791KwBE'; // aesthetic.tez export const CONFIRMATIONS = 3; export const QUOTE_MS = 15 * 60_000; +export const TEZOS_CREDIT_PACKS = Object.freeze([ + Object.freeze({ id:'braincells-600k-v1', amount:300, credits:600_000 }), + CREDIT_PACK, +]); const API = 'https://api.tzkt.io/v1'; const digest = value => createHash('sha256').update(value).digest('hex'); export const paymentError = (status, message) => Object.assign(new Error(message), { status }); -const publicIntent = i => ({ id:i._id, handle:i.handle, status:i.status, credits:i.credits, usd:i.usd, +const publicIntent = i => ({ id:i._id, handle:i.handle, status:i.status, credits:i.credits, usd:i.usd, pack:i.pack || CREDIT_PACK.id, network:'mainnet', recipient:i.recipient, sender:i.sender || null, amountMutez:i.amountMutez || null, expiresAt:i.expiresAt || null, operationHash:i.operationHash || null, confirmations:CONFIRMATIONS }); @@ -29,13 +33,14 @@ export async function chainJSON(path, fetcher = fetch) { return response.json(); } -export function quoteAmount(head, now = new Date()) { +export function quoteAmount(head, now = new Date(), cents = CREDIT_PACK.amount) { if (head.chain !== 'mainnet' || head.chainId !== 'NetXdQprcVkpaWU' || !head.synced || !Number.isFinite(head.quoteUsd) || head.quoteUsd <= 0 || !Number.isSafeInteger(head.level) || !Number.isFinite(Date.parse(head.timestamp)) || Math.abs(+now - Date.parse(head.timestamp)) > 5 * 60_000 || !Number.isSafeInteger(head.quoteLevel) || head.quoteLevel < head.level - 100) { throw paymentError(503, 'A fresh Tezos price is unavailable. Try again shortly.'); } - const mutez = Math.ceil((CREDIT_PACK.amount / 100) / head.quoteUsd * 1e6); + if (!TEZOS_CREDIT_PACKS.some(pack => pack.amount === cents)) throw paymentError(400, 'Unknown braincell pack'); + const mutez = Math.ceil((cents / 100) / head.quoteUsd * 1e6); if (!Number.isSafeInteger(mutez) || mutez < 1) throw paymentError(503, 'Invalid Tezos quote'); return String(mutez); } @@ -91,25 +96,31 @@ export function tezosPayments({ intents, claims, wallets, chain = chainJSON, ver async status(secret) { const intent = await lookup(secret); const expired = !intent.sender && +now() - +new Date(intent.createdAt) > 24 * 3600_000; - return { ...publicIntent(intent), ...(expired ? { status:'expired' } : {}), payload:ownershipPayload(intent) }; + return { ...publicIntent(intent), ...(expired ? { status:'expired' } : {}), payload:ownershipPayload(intent), + offers:TEZOS_CREDIT_PACKS.map(pack => ({ id:pack.id, credits:pack.credits, usd:pack.amount / 100 })) }; }, async quote(secret, proof) { const intent = await lookup(secret); if (intent.status === 'credited') return publicIntent(intent); if (intent.sender) { if (intent.sender !== proof.address) throw paymentError(409, 'This checkout belongs to a different wallet'); + if (proof.pack && proof.pack !== (intent.pack || CREDIT_PACK.id)) throw paymentError(409, 'This checkout already has a fixed pack'); if (+now() > Date.parse(intent.expiresAt)) throw paymentError(409, 'Quote expired. Start a new checkout; do not pay this quote.'); return publicIntent(intent); } if (+now() - +new Date(intent.createdAt) > 24 * 3600_000) throw paymentError(409, 'Checkout expired'); if (!verify(intent, proof)) throw paymentError(403, 'Wallet ownership signature did not verify'); + const pack = TEZOS_CREDIT_PACKS.find(pack => pack.id === (proof.pack ?? CREDIT_PACK.id)); + if (!pack) throw paymentError(400, 'Unknown braincell pack'); const head = await chain('/head'); const quotedAt = now(); - const quote = { status:'quoted', sender:proof.address, amountMutez:quoteAmount(head, quotedAt), + const quote = { status:'quoted', sender:proof.address, pack:pack.id, credits:pack.credits, usd:pack.amount / 100, + amountMutez:quoteAmount(head, quotedAt, pack.amount), quotedAt:quotedAt.toISOString(), expiresAt:new Date(+quotedAt + QUOTE_MS).toISOString(), quoteUsd:head.quoteUsd }; await intents.updateOne({ _id:intent._id, sender:{ $exists:false } }, { $set:quote }); const saved = await lookup(secret); if (saved.sender !== proof.address) throw paymentError(409, 'Another wallet already claimed this checkout'); + if (saved.pack !== pack.id) throw paymentError(409, 'Another quote already fixed the pack for this checkout'); return publicIntent(saved); }, async confirm(secret, operationHash) { diff --git a/system/public/braincells/checkout.css b/system/public/braincells/checkout.css index 4f661a3cf1..9211e51ba5 100644 --- a/system/public/braincells/checkout.css +++ b/system/public/braincells/checkout.css @@ -1,2 +1,3 @@ :root{color-scheme:light dark;font-family:system-ui,-apple-system,sans-serif;background:#f7f1e8;color:#231c2a} *{box-sizing:border-box}body{margin:0}main{max-width:440px;margin:8vh auto;padding:24px}h1{font-size:36px;line-height:1.08;letter-spacing:-1.2px;margin:24px 0 12px}p{line-height:1.5}button{display:block;width:100%;font:inherit;font-weight:650;font-size:19px;padding:16px;border:0;border-radius:14px;background:#7642cc;color:#fff;margin:16px 0;cursor:pointer}.secondary{background:#e5ddeb;color:#352744}button:disabled{opacity:.55;cursor:wait}a{color:#6331b8}#status:empty{display:none}#status{min-height:1.5em}#wallet{font-size:14px;overflow-wrap:anywhere}#recipient{font-size:14px}#return{display:block;margin-top:28px;font-size:19px}[hidden]{display:none!important}.brain{image-rendering:pixelated}@media(prefers-color-scheme:dark){:root{background:#19141f;color:#f7f1e8}a{color:#c5a5ff}.secondary{background:#382b45;color:#f7f1e8}}@media(max-height:650px){main{margin:0 auto}} +#pack-label{display:block;font-size:16px}select{display:block;width:100%;font:inherit;font-size:18px;padding:12px;margin-top:8px;border:1px solid #999;border-radius:10px;background:transparent;color:inherit} diff --git a/system/public/braincells/checkout.mjs b/system/public/braincells/checkout.mjs index 2306ab4cef..4ae238267b 100644 --- a/system/public/braincells/checkout.mjs +++ b/system/public/braincells/checkout.mjs @@ -5,7 +5,7 @@ if (/^[a-f0-9]{64}$/.test(fragment)) { sessionStorage.setItem('ac-tezos-checkout', fragment); history.replaceState(null, '', location.pathname); } -let client, intent, busy = false, polling = false; +let client, intent, offers = [], busy = false, polling = false; const say = text => { $('status').textContent = text; }; async function api(action, body = {}) { const r = await fetch('/api/easel-tezos', { method:'POST', headers:{ 'Content-Type':'application/json', Authorization:`Bearer ${secret}` }, @@ -16,6 +16,15 @@ async function api(action, body = {}) { } function show(next) { intent = next; + if (next.offers) { + offers = next.offers; + $('pack').replaceChildren(...offers.map(offer => new Option(`$${offer.usd} · ${offer.credits.toLocaleString()} braincells`, offer.id))); + } + const savedPack = sessionStorage.getItem(`ac-tezos-pack:${intent.id}`); + $('pack').value = intent.sender ? intent.pack : offers.some(offer => offer.id === savedPack) ? savedPack : intent.pack; + $('pack-label').hidden = Boolean(intent.sender) || intent.status !== 'created'; + $('pack').disabled = busy || Boolean(intent.sender); + showPack(); $('account').textContent = `For @${intent.handle}`; $('recipient').hidden = false; $('return').hidden = false; @@ -32,6 +41,12 @@ function show(next) { if (intent.status === 'credited') { say('Braincells added. They’re ready to use in AC.'); sessionStorage.removeItem(`ac-tezos-op:${intent.id}`); } else if (expired || intent.status === 'expired') say('Quote expired. Check an existing payment, or return to Whistlegraph for a new checkout.'); } +function showPack() { + const pack = !intent.sender && offers.find(offer => offer.id === $('pack').value) || intent; + $('quantity').textContent = `${pack.credits.toLocaleString()} braincells`; + if (!intent.sender) $('price').textContent = `$${pack.usd} in tez, plus the wallet’s network fee.`; +} +$('pack').onchange = () => { sessionStorage.setItem(`ac-tezos-pack:${intent.id}`, $('pack').value); showPack(); }; async function useWallet() { if (!client) client = new window.beacon.DAppClient({ name:'AC braincells', network:{ type:'mainnet' }, enableMetrics:false, appUrl:'https://aesthetic.computer/braincells/', iconUrl:'https://aesthetic.computer/aesthetic.computer/braincell.svg', disableDefaultEvents:false }); @@ -44,16 +59,17 @@ async function useWallet() { async function action(work) { if (busy) return; busy = true; - for (const id of ['connect','pay','check']) $(id).disabled = true; + for (const id of ['connect','pay','check','pack']) $(id).disabled = true; try { await work(); } catch (error) { say(error.message || 'Wallet request interrupted. Check payment before trying again.'); } - finally { busy = false; for (const id of ['connect','pay','check']) $(id).disabled = false; } + finally { busy = false; for (const id of ['connect','pay','check']) $(id).disabled = false; $('pack').disabled = Boolean(intent.sender); } } $('connect').onclick = () => action(async () => { + const pack = $('pack').value; say('Choose Temple or another Tezos wallet.'); const account = await useWallet(); say('Approve the account message in your wallet. This does not move tez.'); const signed = await client.requestSignPayload({ signingType:'micheline', payload:intent.payload, sourceAddress:account.address }); - show(await api('quote', { address:account.address, publicKey:account.publicKey, signature:signed.signature })); + show(await api('quote', { pack, address:account.address, publicKey:account.publicKey, signature:signed.signature })); say('Review the amount, then pay in your wallet. Quote lasts 15 minutes.'); }); $('pay').onclick = () => action(async () => { diff --git a/system/public/braincells/index.html b/system/public/braincells/index.html index a0912f16a5..ec4e5c0560 100644 --- a/system/public/braincells/index.html +++ b/system/public/braincells/index.html @@ -8,9 +8,10 @@
-

1,000,000 braincells

+

Braincells

Loading checkout…

-

$5 in tez, plus the wallet’s network fee.

+ +

diff --git a/system/tests/tezos-checkout.browser.mjs b/system/tests/tezos-checkout.browser.mjs index 12b652ddcb..125e4293de 100644 --- a/system/tests/tezos-checkout.browser.mjs +++ b/system/tests/tezos-checkout.browser.mjs @@ -17,18 +17,21 @@ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch({ headless:true, channel:process.env.PLAYWRIGHT_CHANNEL || 'chrome' }); try { + for (const [pack, credits, usd, amount] of [['braincells-600k-v1',600_000,3,'7500000'], ['braincells-1m-v1',1_000_000,5,'12500000']]) { + const tez = String(Number(amount) / 1e6); const page = await browser.newPage({ ...devices['iPhone 13'] }); const errors = []; page.on('pageerror', e => errors.push(e.message)); const secret = 'a'.repeat(64), sender = 'tz1inPpZMzFUv5mkmqDMEC8sYxmEq53vxRhw'; - let intent = { id:'test', handle:'tester', status:'created', credits:1_000_000, usd:5, + let intent = { id:'test', handle:'tester', status:'created', credits:1_000_000, usd:5, pack:'braincells-1m-v1', + offers:[{id:'braincells-600k-v1',credits:600_000,usd:3},{id:'braincells-1m-v1',credits:1_000_000,usd:5}], recipient:'tz1gkf8EexComFBJvjtT1zdsisdah791KwBE', payload:'05010000000474657374' }; let phase = 'confirming'; await page.route('**/api/easel-tezos', async route => { assert.equal(route.request().headers().authorization, `Bearer ${secret}`); const request = route.request().postDataJSON(); if (request.action === 'quote') { - assert.equal(request.address, sender); assert.equal(request.signature, 'fixture-signature'); - intent = { ...intent, status:'quoted', sender, amountMutez:'12500000', expiresAt:new Date(Date.now() + 900_000).toISOString() }; + assert.equal(request.address, sender); assert.equal(request.signature, 'fixture-signature'); assert.equal(request.pack, pack); + intent = { ...intent, status:'quoted', sender, pack, credits, usd, amountMutez:amount, expiresAt:new Date(Date.now() + 900_000).toISOString() }; } const value = request.action === 'confirm' ? { ...intent, status:phase } : intent; await route.fulfill({ json:value }); @@ -43,23 +46,29 @@ try { async requestOperation(request){ window.operations.push(request); return {transactionHash:'o'+'a'.repeat(50)}; } }};` })); await page.goto(`${origin}/braincells/#${secret}`); + await page.getByLabel('Amount', {exact:true}).selectOption(pack); + await page.getByRole('heading', {name:`${credits.toLocaleString('en-US')} braincells`,exact:true}).waitFor(); + await page.reload(); + assert.equal(await page.getByLabel('Amount', {exact:true}).inputValue(),pack,'selected pack survives reload'); await page.getByRole('button', { name:'Connect wallet', exact:true }).click(); - await page.getByRole('button', { name:'Pay 12.5 tez', exact:true }).waitFor(); + await page.getByRole('button', { name:`Pay ${tez} tez`, exact:true }).waitFor(); assert.equal(new URL(page.url()).hash, ''); assert.equal(await page.evaluate(() => window.operations.length), 0, 'connecting/signing never sends funds'); - await page.getByRole('button', { name:'Pay 12.5 tez', exact:true }).click(); + await page.getByRole('button', { name:`Pay ${tez} tez`, exact:true }).click(); await page.getByText('Payment seen. Waiting for three Tezos confirmations…', { exact:true }).waitFor(); - assert.equal(await page.getByRole('button', { name:'Pay 12.5 tez', exact:true }).isVisible(), false); + assert.equal(await page.getByRole('button', { name:`Pay ${tez} tez`, exact:true }).isVisible(), false); const operations = await page.evaluate(() => window.operations); assert.equal(operations.length, 1); - assert.deepEqual(operations[0].operationDetails, [{ kind:'transaction', destination:intent.recipient, amount:'12500000' }]); + assert.deepEqual(operations[0].operationDetails, [{ kind:'transaction', destination:intent.recipient, amount }]); await page.reload(); await page.getByText('Payment seen. Waiting for three Tezos confirmations…', { exact:true }).waitFor(); - assert.equal(await page.getByRole('button', { name:'Pay 12.5 tez', exact:true }).isVisible(), false, 'reload cannot offer a second payment'); + assert.equal(await page.getByRole('button', { name:`Pay ${tez} tez`, exact:true }).isVisible(), false, 'reload cannot offer a second payment'); phase = 'credited'; await page.getByRole('button', { name:'Check payment', exact:true }).click(); await page.getByText('Braincells added. They’re ready to use in AC.', { exact:true }).waitFor(); assert.equal(await page.getByRole('button', { name:'Check payment', exact:true }).isVisible(), false); assert.deepEqual(errors, []); - console.log('PASS: connect, exact quote, explicit payment, reload recovery, pending and credited states.'); + console.log(`PASS: $${usd} pack, connect, exact quote, explicit payment, reload recovery, pending and credited states.`); + await page.close(); + } } finally { await browser.close(); server.close(); } diff --git a/system/tests/tezos-credits.test.mjs b/system/tests/tezos-credits.test.mjs index aeef3a2d98..1eb1b39c14 100644 --- a/system/tests/tezos-credits.test.mjs +++ b/system/tests/tezos-credits.test.mjs @@ -35,7 +35,7 @@ function collection() { }, }; } -async function fixture() { +async function fixture(pack) { const intents = collection(), claims = collection(), wallets = collection(); let clock = time; const state = { head:{ ...head }, txs:[] }; @@ -43,7 +43,7 @@ async function fixture() { chain:async path => path === '/head' ? state.head : state.txs }); const created = await p.create('user', '@test'); const secret = new URL(created.checkoutURL).hash.slice(1); - const quoted = await p.quote(secret, { address:sender, signature:'valid' }); + const quoted = await p.quote(secret, { address:sender, signature:'valid', pack }); const intent = [...intents.docs.values()][0]; const tx = { id:1234, hash, status:'applied', sender:{ address:sender }, target:{ address:TEZOS_TREASURY }, amount:Number(quoted.amountMutez), timestamp:new Date(+time + 1000).toISOString(), level:100 }; @@ -52,11 +52,29 @@ async function fixture() { test('quotes use the existing $5 pack and reject stale or wrong-network prices', () => { assert.equal(quoteAmount(head, time), '12500000'); + assert.equal(quoteAmount(head, time, 300), '7500000'); + assert.throws(() => quoteAmount(head, time, 1), /Unknown/); for (const patch of [{ quoteUsd:0 }, { synced:false }, { chain:'ghostnet' }, { chainId:'other' }, { timestamp:'bad' }, { quoteLevel:0 }]) assert.throws(() => quoteAmount({ ...head, ...patch }, time), /price|quote/); assert.throws(() => quoteAmount(head, new Date(+time + 3600_000)), /fresh/); }); +test('$3 buys exactly 600,000 braincells and a signed quote cannot change packs', async () => { + const f = await fixture('braincells-600k-v1'); + assert.equal(f.quoted.usd, 3); + assert.equal(f.quoted.credits, 600_000); + assert.equal(f.quoted.amountMutez, '7500000'); + await assert.rejects(f.p.quote(f.secret, { address:sender, signature:'valid', pack:'braincells-1m-v1' }), /fixed pack/); + f.state.txs = [f.tx]; + assert.equal((await f.p.confirm(f.secret, hash)).credits, 600_000); + await f.p.confirm(f.secret, hash); + assert.equal(f.wallets.docs.get('user').balance, 600_000); + const created = await f.p.create('user', '@test'); + const secret = new URL(created.checkoutURL).hash.slice(1); + await assert.rejects(f.p.quote(secret, { address:sender, signature:'valid', pack:'invented', credits:99_000_000, usd:0.01 }), /Unknown/); + assert.equal((await f.p.status(secret)).status, 'created'); +}); + test('display conversions expose a dated, expiring rate without requiring a checkout', async () => { const price = () => displayPrice({ chain:async () => head, now:time }); const response = await createHandler({ price })({ httpMethod:'POST', body:JSON.stringify({ action:'price' }) }); diff --git a/tezos/README.md b/tezos/README.md index 9d4c315c8e..f9a73c257b 100644 --- a/tezos/README.md +++ b/tezos/README.md @@ -66,8 +66,9 @@ ### Braincell purchases Whistlegraph opens `/braincells/` in the browser to pair with Temple or another -Beacon wallet. `/api/easel-tezos` sells the existing $5 / 1,000,000 braincell pack -to `aesthetic.tez`, using a 15-minute mainnet quote. A signed account message binds +Beacon wallet. `/api/easel-tezos` offers $3 / 600,000 and $5 / 1,000,000 braincell packs +to `aesthetic.tez`, using a 15-minute mainnet quote. Choose the pack before +connecting the wallet; the first signed quote fixes its price and credit amount. A signed account message binds the payer; TzKT must report the exact transfer and three confirmations before AC adds credit. No wallet keys are stored by AC. -- 2.51.2