feat(payments): wire Cashu as the primary entry-fee UX, fix anonymous-bot ranked auth
CI / check (push) Has been cancelled
CI / check (push) Has been cancelled
Two pieces, both user-directed, completing what 7341ca0 only configured:
1. WalletConnect.vue: Cashu token paste is now the PRIMARY entry-fee path
(submitCashuToken() already existed in useWallet.ts but was never called
from any UI — added the missing wiring). Lightning/NWC is now secondary,
behind an explicit "or connect a Lightning wallet instead" toggle.
Emits `cashu-paid` with the redeemed paymentId; JoinBoutPage.vue's
fightRanked() uses it directly instead of calling payEntryFee()
(Lightning-only) when present — no duplicate invoice/charge.
2. queue.ts's POST /join-ranked/:botId required a nostr pubkey for
ownership verification, full stop. Confirmed live during testing:
anonymous poll-mode bots (the primary registration path for AI agents
per BOTFIGHTS.md) have publicKey: null — staked fights were completely
unusable for that entire audience, silently. Now accepts EITHER a
pubkey OR Authorization: Bot <id>:<secret> (same bot-auth every other
anonymous-bot endpoint already uses) as proof of ownership.
Verified: full server typecheck clean; payments.test.ts (23) and
queue.test.ts (8) unchanged and passing; full frontend suite (101 tests,
13 files) passing, including useWallet.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useWallet } from '../composables/useWallet'
|
import { useWallet } from '../composables/useWallet'
|
||||||
|
|
||||||
const { isWalletConnected, walletMethod, paymentStatus, disconnectWallet, connectNWC, connectLightningAddress } = useWallet()
|
const props = defineProps<{ botId?: string }>()
|
||||||
|
const emit = defineEmits<{ 'cashu-paid': [paymentId: string] }>()
|
||||||
|
|
||||||
|
const { isWalletConnected, walletMethod, paymentStatus, disconnectWallet, connectNWC, connectLightningAddress, submitCashuToken } = useWallet()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
|
const showLightningOptions = ref(false)
|
||||||
const nwcInput = ref('')
|
const nwcInput = ref('')
|
||||||
const lnAddressInput = ref('')
|
const lnAddressInput = ref('')
|
||||||
|
const cashuInput = ref('')
|
||||||
const connectError = ref('')
|
const connectError = ref('')
|
||||||
const isConnecting = ref(false)
|
const isConnecting = ref(false)
|
||||||
|
const isPayingCashu = ref(false)
|
||||||
|
const cashuError = ref('')
|
||||||
|
|
||||||
|
async function handlePayCashu() {
|
||||||
|
if (!cashuInput.value.trim() || !props.botId) return
|
||||||
|
isPayingCashu.value = true
|
||||||
|
cashuError.value = ''
|
||||||
|
try {
|
||||||
|
// One-time bearer payment, not a persistent "connection" like NWC/LN
|
||||||
|
// address — submitting the token IS paying the 21-sat entry fee right
|
||||||
|
// now. Parent (JoinBoutPage.vue) uses the returned paymentId directly
|
||||||
|
// with POST /api/queue/join-ranked, bypassing payEntryFee() entirely.
|
||||||
|
const paymentId = await submitCashuToken(props.botId, cashuInput.value.trim())
|
||||||
|
cashuInput.value = ''
|
||||||
|
emit('cashu-paid', paymentId)
|
||||||
|
} catch (err) {
|
||||||
|
cashuError.value = err instanceof Error ? err.message : 'Cashu payment failed'
|
||||||
|
}
|
||||||
|
isPayingCashu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function handleConnectNWC() {
|
async function handleConnectNWC() {
|
||||||
if (!nwcInput.value.trim()) return
|
if (!nwcInput.value.trim()) return
|
||||||
@@ -57,10 +82,10 @@ async function handleDisconnect() {
|
|||||||
<span class="font-display font-bold text-xs tracking-wider text-green-400 animate-pulse">LOCKED IN</span>
|
<span class="font-display font-bold text-xs tracking-wider text-green-400 animate-pulse">LOCKED IN</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connected state -->
|
<!-- Connected state (NWC/LN address — persistent wallet) -->
|
||||||
<div v-else-if="isWalletConnected" class="flex items-center justify-center gap-2 py-2">
|
<div v-else-if="isWalletConnected" class="flex items-center justify-center gap-2 py-2">
|
||||||
<span class="text-neon-cyan">⚡</span>
|
<span class="text-neon-cyan">⚡</span>
|
||||||
<span class="font-display font-bold text-[10px] tracking-wider text-neon-cyan">WALLET READY</span>
|
<span class="font-display font-bold text-[10px] tracking-wider text-neon-cyan">WALLET READY ({{ walletMethod }})</span>
|
||||||
<button
|
<button
|
||||||
class="font-mono text-[9px] text-text-muted hover:text-ko transition-colors ml-2 underline"
|
class="font-mono text-[9px] text-text-muted hover:text-ko transition-colors ml-2 underline"
|
||||||
@click="handleDisconnect"
|
@click="handleDisconnect"
|
||||||
@@ -69,7 +94,7 @@ async function handleDisconnect() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not connected -->
|
<!-- Not connected — Cashu is the primary path, Lightning/NWC is secondary -->
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<button
|
<button
|
||||||
v-if="!isExpanded"
|
v-if="!isExpanded"
|
||||||
@@ -78,13 +103,60 @@ async function handleDisconnect() {
|
|||||||
hover:bg-neon-cyan/10 transition-all"
|
hover:bg-neon-cyan/10 transition-all"
|
||||||
@click="isExpanded = true"
|
@click="isExpanded = true"
|
||||||
>
|
>
|
||||||
⚡ CONNECT WALLET
|
🥜 PAY 21 SATS WITH CASHU
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-else class="border border-border p-3 space-y-3">
|
<div v-else class="border border-border p-3 space-y-3">
|
||||||
<p class="font-display font-bold text-[10px] tracking-wider text-text-secondary text-center">CONNECT WALLET</p>
|
<p class="font-display font-bold text-[10px] tracking-wider text-text-secondary text-center">PAY YOUR ENTRY FEE</p>
|
||||||
|
|
||||||
|
<!-- Cashu token — primary path. One paste = paid, no persistent
|
||||||
|
"connection" step, works for any wallet (Minibits, etc.) that can
|
||||||
|
mint an ecash token. -->
|
||||||
|
<div>
|
||||||
|
<label class="font-mono text-[9px] text-neon-cyan block mb-1">🥜 CASHU TOKEN (21 SATS) — RECOMMENDED</label>
|
||||||
|
<input
|
||||||
|
v-model="cashuInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="cashuA..."
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full bg-surface border border-neon-cyan/40 px-2 py-1.5 font-mono text-[10px] text-text-primary
|
||||||
|
focus:border-neon-cyan focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="w-full mt-1 py-1.5 bg-neon-cyan/10 border border-neon-cyan/30 text-neon-cyan
|
||||||
|
font-display font-bold text-[9px] tracking-wider
|
||||||
|
hover:bg-neon-cyan/20 transition-all disabled:opacity-50"
|
||||||
|
:disabled="!cashuInput.trim() || isPayingCashu || !botId"
|
||||||
|
@click="handlePayCashu"
|
||||||
|
>
|
||||||
|
{{ isPayingCashu ? 'PAYING...' : '🥜 PAY WITH CASHU' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="cashuError" class="font-mono text-[9px] text-ko mt-1">{{ cashuError }}</p>
|
||||||
|
<p class="font-mono text-[8px] text-text-muted/60 mt-1 leading-relaxed">
|
||||||
|
Mint a 21-sat ecash token from any Cashu wallet (e.g.
|
||||||
|
<a href="https://www.minibits.cash" target="_blank" rel="noopener" class="underline">Minibits</a>)
|
||||||
|
and paste it here — this pays your entry fee immediately, no ongoing wallet connection needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightning / NWC — secondary, for a persistent wallet connection
|
||||||
|
(also used for receiving payouts). -->
|
||||||
|
<button
|
||||||
|
v-if="!showLightningOptions"
|
||||||
|
class="w-full py-1.5 font-mono text-[9px] text-text-muted hover:text-text-secondary
|
||||||
|
border border-border/50 transition-colors"
|
||||||
|
@click="showLightningOptions = true"
|
||||||
|
>
|
||||||
|
or connect a Lightning wallet instead ▾
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 border-t border-border" />
|
||||||
|
<span class="font-mono text-[8px] text-text-muted">LIGHTNING (SECONDARY)</span>
|
||||||
|
<div class="flex-1 border-t border-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- NWC input -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="font-mono text-[9px] text-text-muted block mb-1">NWC CONNECTION STRING</label>
|
<label class="font-mono text-[9px] text-text-muted block mb-1">NWC CONNECTION STRING</label>
|
||||||
<input
|
<input
|
||||||
@@ -95,9 +167,9 @@ async function handleDisconnect() {
|
|||||||
focus:border-neon-cyan/50 focus:outline-none"
|
focus:border-neon-cyan/50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="w-full mt-1 py-1.5 bg-neon-cyan/10 border border-neon-cyan/30 text-neon-cyan
|
class="w-full mt-1 py-1.5 bg-neon-purple/10 border border-neon-purple/30 text-neon-purple
|
||||||
font-display font-bold text-[9px] tracking-wider
|
font-display font-bold text-[9px] tracking-wider
|
||||||
hover:bg-neon-cyan/20 transition-all disabled:opacity-50"
|
hover:bg-neon-purple/20 transition-all disabled:opacity-50"
|
||||||
:disabled="!nwcInput.trim() || isConnecting"
|
:disabled="!nwcInput.trim() || isConnecting"
|
||||||
@click="handleConnectNWC"
|
@click="handleConnectNWC"
|
||||||
>
|
>
|
||||||
@@ -135,6 +207,7 @@ async function handleDisconnect() {
|
|||||||
<div v-if="connectError" class="text-center">
|
<div v-if="connectError" class="text-center">
|
||||||
<p class="font-mono text-[9px] text-ko">{{ connectError }}</p>
|
<p class="font-mono text-[9px] text-ko">{{ connectError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="w-full py-1 font-mono text-[9px] text-text-muted hover:text-text-secondary transition-colors"
|
class="w-full py-1 font-mono text-[9px] text-text-muted hover:text-text-secondary transition-colors"
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ let rateLimitTimer: ReturnType<typeof setInterval> | null = null
|
|||||||
const isJoining = ref(false)
|
const isJoining = ref(false)
|
||||||
const isJoiningRanked = ref(false)
|
const isJoiningRanked = ref(false)
|
||||||
const isJoiningPractice = ref(false)
|
const isJoiningPractice = ref(false)
|
||||||
|
// Set by WalletConnect's cashu-paid event — a Cashu token was already
|
||||||
|
// submitted and redeemed (POST /api/payments/submit-cashu already
|
||||||
|
// returned a confirmed paymentId). fightRanked() uses this directly
|
||||||
|
// instead of calling payEntryFee() (the Lightning/NWC path).
|
||||||
|
const cashuPaymentId = ref<string | null>(null)
|
||||||
|
function onCashuPaid(paymentId: string) {
|
||||||
|
cashuPaymentId.value = paymentId
|
||||||
|
fightRanked()
|
||||||
|
}
|
||||||
|
|
||||||
// Bot connection mode
|
// Bot connection mode
|
||||||
const isVerifyingWebhook = ref(false)
|
const isVerifyingWebhook = ref(false)
|
||||||
@@ -703,7 +712,11 @@ async function fightRanked() {
|
|||||||
isJoiningRanked.value = true
|
isJoiningRanked.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const paymentId = await payEntryFee(bot.value.id)
|
// Cashu (primary path): a token was already submitted+redeemed by
|
||||||
|
// WalletConnect's cashu-paid event — reuse that paymentId directly,
|
||||||
|
// don't create a duplicate Lightning invoice via payEntryFee().
|
||||||
|
const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id)
|
||||||
|
cashuPaymentId.value = null
|
||||||
const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1583,7 +1596,7 @@ function handleSignOut() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Wallet connect (shown if no wallet) -->
|
<!-- Wallet connect (shown if no wallet) -->
|
||||||
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
|
<WalletConnect v-if="!isHumanMode && !bot.isHuman" :bot-id="bot.id" @cashu-paid="onCashuPaid" />
|
||||||
|
|
||||||
<!-- Training fight — against bland classic bots, free -->
|
<!-- Training fight — against bland classic bots, free -->
|
||||||
<div class="pt-2 border-t border-border/30">
|
<div class="pt-2 border-t border-border/30">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine
|
|||||||
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
import { joinRankedSchema, sanitizeError } from '../lib/validators.js'
|
import { joinRankedSchema, sanitizeError } from '../lib/validators.js'
|
||||||
|
import { authenticateBot } from '../middleware/bot-auth.js'
|
||||||
|
|
||||||
export const queueRouter = new Hono()
|
export const queueRouter = new Hono()
|
||||||
|
|
||||||
@@ -53,7 +54,15 @@ queueRouter.get('/ranked-status', (c) => {
|
|||||||
return c.json(getRankedQueueStatus())
|
return c.json(getRankedQueueStatus())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Join ranked queue — requires confirmed payment + bot ownership
|
// Join ranked queue — requires confirmed payment + bot ownership.
|
||||||
|
// Ownership can be proven either way, since ranked/staked fights are for
|
||||||
|
// BOTH audiences (not just nostr-signed-in humans):
|
||||||
|
// 1. pubkey (nostr-authenticated bots, the web UI's own JWT session flow)
|
||||||
|
// 2. Authorization: Bot <id>:<secret> (anonymous poll-mode bots — the
|
||||||
|
// primary registration path for AI agents per BOTFIGHTS.md, which
|
||||||
|
// never have a publicKey at all: confirmed live, publicKey is null
|
||||||
|
// for every bot registered via POST /api/bots). Without this, staking
|
||||||
|
// was silently unusable for the whole poll-mode/AI-agent audience.
|
||||||
queueRouter.post('/join-ranked/:botId', async (c) => {
|
queueRouter.post('/join-ranked/:botId', async (c) => {
|
||||||
const botId = c.req.param('botId')
|
const botId = c.req.param('botId')
|
||||||
const parsed = joinRankedSchema.safeParse(await c.req.json().catch(() => ({})))
|
const parsed = joinRankedSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
@@ -64,14 +73,21 @@ queueRouter.post('/join-ranked/:botId', async (c) => {
|
|||||||
|
|
||||||
// Verify bot ownership in production
|
// Verify bot ownership in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
||||||
return c.json({ error: 'Missing pubkey' }, 400)
|
|
||||||
}
|
|
||||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||||
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||||
return c.json({ error: 'Unauthorized' }, 403)
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No pubkey supplied — fall back to bot-secret auth (Authorization
|
||||||
|
// header or ?bot_id=&secret= query params, same as /api/fights/poll).
|
||||||
|
const botOrRes = await authenticateBot(c)
|
||||||
|
if (botOrRes instanceof Response) return botOrRes
|
||||||
|
if (botOrRes.botId !== botId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user