feat: recorded-episode pricing, Cashu-token payment, podcast editing
- Editable podcast settings: new /podcasts/:id/settings page, reusing PodcastForm.vue in an edit mode (PUT instead of POST) since it was previously create-only with no way to fix a field (e.g. lightning address) after the fact. - Recorded episodes can now be priced same as uploads: "Publish recording" gained an optional price_sats field, wired through the existing episode paywall machinery. Live streams themselves stay unpaywalled by design — only the resulting recording can be priced. - Accept Cashu tokens as an alternative to a Lightning invoice: POST .../purchase/token redeems a pasted token directly (via the mint's swap/receive flow) and finalizes the purchase in one step, no quote/confirm round trip. A token worth more than the price is treated as a tip (seller gets the full amount); worth less is rejected. Added a "pay with a Cashu token instead" option next to the existing invoice flow. - cashu.ts: fixed payout() always requesting an invoice for the full held balance with no room for the mint's routing-fee reserve, which made a balance that exactly matched one sale's price permanently unwithdrawable (needed slightly more than held to cover the fee). Now shrinks the request and requotes once if the first quote doesn't fit. - docker-compose.yml / mediamtx.yml: renamed the podsteadr container's DNS alias away from the literal string "podsteadr" — on a host whose own hostname is "podsteadr", cloud-init's self-hostname /etc/hosts entry shadowed the container-network alias, so mediamtx's auth webhook callback resolved to the wrong address and rejected every RTMP publish attempt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -3,7 +3,13 @@ name: podsteadr
|
||||
services:
|
||||
podsteadr:
|
||||
build: .
|
||||
container_name: podsteadr
|
||||
# Container/DNS-alias name deliberately NOT "podsteadr" — on hosts whose own hostname is
|
||||
# "podsteadr" (e.g. a VPS named after the app), the host's own /etc/hosts self-hostname
|
||||
# entry (127.0.1.1 podsteadr, added by cloud-init) shadows the container network's DNS
|
||||
# alias for other containers looking up "podsteadr", so mediamtx's auth-webhook callback
|
||||
# resolved to the host's loopback instead of this container and every RTMP publish got
|
||||
# rejected with "connection refused" (observed on podsteadr.atobitcoin.io, 2026-07-30).
|
||||
container_name: podsteadr-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8095:8095"
|
||||
|
||||
@@ -37,6 +37,9 @@ const confirming = ref(false);
|
||||
const confirmError = ref('');
|
||||
const purchased = ref(false);
|
||||
|
||||
const tokenInput = ref('');
|
||||
const redeemingToken = ref(false);
|
||||
|
||||
const sortedRows = computed(() =>
|
||||
[...rows.value].sort((a, b) => {
|
||||
if (sortKey.value === 'latencyMs') return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity);
|
||||
@@ -123,10 +126,28 @@ async function checkPaid() {
|
||||
}
|
||||
}
|
||||
|
||||
async function payWithToken(row: Row) {
|
||||
confirmError.value = '';
|
||||
redeemingToken.value = true;
|
||||
try {
|
||||
await api.post(`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/purchase/token`, {
|
||||
source: row.type === 'producer' ? 'producer' : row.pubkey,
|
||||
token: tokenInput.value.trim(),
|
||||
});
|
||||
purchased.value = true;
|
||||
emit('purchased');
|
||||
} catch (err) {
|
||||
confirmError.value = (err as Error).message;
|
||||
} finally {
|
||||
redeemingToken.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelBuy() {
|
||||
buyingPubkey.value = null;
|
||||
invoice.value = '';
|
||||
quoteId.value = '';
|
||||
tokenInput.value = '';
|
||||
confirmError.value = '';
|
||||
}
|
||||
</script>
|
||||
@@ -172,6 +193,23 @@ function cancelBuy() {
|
||||
{{ confirming ? 'Checking…' : "I've paid — unlock" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 border-t border-white/10 pt-3">
|
||||
<p class="text-xs text-white/50">— or pay with a Cashu token instead —</p>
|
||||
<textarea
|
||||
v-model="tokenInput"
|
||||
class="input font-mono text-xs"
|
||||
rows="2"
|
||||
placeholder="cashuB…"
|
||||
/>
|
||||
<button
|
||||
class="btn-secondary w-full"
|
||||
:disabled="redeemingToken || !tokenInput.trim()"
|
||||
@click="payWithToken(row)"
|
||||
>
|
||||
{{ redeemingToken ? 'Redeeming…' : 'Redeem token — unlock' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-sm font-medium text-green-400">🎉 Purchased — you now have access.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
// Wizard questions for creating (or editing) a podcast, including the
|
||||
// Wizard questions for creating a podcast, or editing an existing one — including the
|
||||
// lightning address used for the RSS value-for-value block.
|
||||
import { reactive, ref } from 'vue';
|
||||
import { api } from '../lib/api';
|
||||
@@ -15,17 +15,25 @@ export interface PodcastPayload {
|
||||
lightning_address?: string | null;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ created: [podcast: { id: string; title: string; feed_url: string }] }>();
|
||||
const props = defineProps<{
|
||||
// Pass an existing podcast to edit it in place instead of creating a new one.
|
||||
podcast?: PodcastPayload & { id: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [podcast: { id: string; title: string; feed_url: string }];
|
||||
saved: [podcast: { id: string; title: string; feed_url: string }];
|
||||
}>();
|
||||
|
||||
const form = reactive<PodcastPayload>({
|
||||
title: '',
|
||||
description: '',
|
||||
author: '',
|
||||
image_url: '',
|
||||
language: 'en',
|
||||
category: 'Technology',
|
||||
explicit: false,
|
||||
lightning_address: '',
|
||||
title: props.podcast?.title ?? '',
|
||||
description: props.podcast?.description ?? '',
|
||||
author: props.podcast?.author ?? '',
|
||||
image_url: props.podcast?.image_url ?? '',
|
||||
language: props.podcast?.language ?? 'en',
|
||||
category: props.podcast?.category ?? 'Technology',
|
||||
explicit: props.podcast?.explicit ?? false,
|
||||
lightning_address: props.podcast?.lightning_address ?? '',
|
||||
});
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
@@ -40,12 +48,21 @@ async function submit() {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
const created = await api.post<{ id: string; title: string; feed_url: string }>('/api/podcasts', {
|
||||
const payload = {
|
||||
...form,
|
||||
image_url: form.image_url || null,
|
||||
lightning_address: form.lightning_address || null,
|
||||
});
|
||||
emit('created', created);
|
||||
};
|
||||
if (props.podcast) {
|
||||
const updated = await api.put<{ id: string; title: string; feed_url: string }>(
|
||||
`/api/podcasts/${props.podcast.id}`,
|
||||
payload,
|
||||
);
|
||||
emit('saved', updated);
|
||||
} else {
|
||||
const created = await api.post<{ id: string; title: string; feed_url: string }>('/api/podcasts', payload);
|
||||
emit('created', created);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message;
|
||||
} finally {
|
||||
@@ -103,7 +120,7 @@ async function submit() {
|
||||
|
||||
<p v-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
|
||||
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
|
||||
{{ busy ? 'Creating…' : 'Create podcast' }}
|
||||
{{ busy ? (podcast ? 'Saving…' : 'Creating…') : (podcast ? 'Save changes' : 'Create podcast') }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const router = createRouter({
|
||||
{ path: '/upload', component: () => import('./views/wizard/EpisodeWizard.vue') },
|
||||
{ path: '/live', component: () => import('./views/wizard/LiveWizard.vue') },
|
||||
{ path: '/streams/:id', component: () => import('./views/StreamDashboard.vue') },
|
||||
{ path: '/podcasts/:id/settings', component: () => import('./views/PodcastSettingsView.vue') },
|
||||
{ path: '/podcasts/:id/episodes/:eid', component: () => import('./views/EpisodeDetailView.vue') },
|
||||
{ path: '/earnings', component: () => import('./views/EarningsView.vue') },
|
||||
{ path: '/settings', component: () => import('./views/SettingsView.vue') },
|
||||
|
||||
@@ -66,7 +66,10 @@ onMounted(async () => {
|
||||
<ul class="space-y-2">
|
||||
<li v-for="p in podcasts" :key="p.id" class="card flex items-center justify-between !p-4">
|
||||
<span class="font-medium">{{ p.title }}</span>
|
||||
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-orange-400 underline">RSS feed</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-orange-400 underline">RSS feed</a>
|
||||
<RouterLink :to="`/podcasts/${p.id}/settings`" class="text-sm text-orange-400 underline">Edit</RouterLink>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { api } from '../lib/api';
|
||||
import PodcastForm, { type PodcastPayload } from '../components/PodcastForm.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const podcastId = route.params.id as string;
|
||||
|
||||
const podcast = ref<(PodcastPayload & { id: string }) | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const saved = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
podcast.value = await api.get<PodcastPayload & { id: string }>(`/api/podcasts/${podcastId}`);
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function onSaved(): void {
|
||||
saved.value = true;
|
||||
setTimeout(() => router.push('/'), 800);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Podcast settings</h1>
|
||||
<p v-if="loading" class="text-sm text-white/50">Loading…</p>
|
||||
<p v-else-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
|
||||
<div v-else class="card">
|
||||
<PodcastForm :podcast="podcast!" @saved="onSaved" />
|
||||
<p v-if="saved" class="mt-4 rounded-lg bg-green-500/20 border border-green-500/40 p-3 text-sm text-green-200">
|
||||
Saved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,6 +95,7 @@ async function stopBrowserPublish() {
|
||||
const recordings = ref<RecordingFile[]>([]);
|
||||
const podcasts = ref<PodcastSummary[]>([]);
|
||||
const publishTarget = ref('');
|
||||
const publishPriceSats = ref<number | null>(null);
|
||||
const publishingFile = ref('');
|
||||
const publishedFeed = ref('');
|
||||
|
||||
@@ -112,6 +113,7 @@ async function publishRecording(file: string) {
|
||||
podcast_id: publishTarget.value,
|
||||
title: `${stream.value?.title ?? 'Live stream'} — recording`,
|
||||
description: stream.value?.summary ?? '',
|
||||
price_sats: publishPriceSats.value || undefined,
|
||||
});
|
||||
publishedFeed.value = podcasts.value.find((p) => p.id === publishTarget.value)?.feed_url ?? '';
|
||||
} catch (err) {
|
||||
@@ -184,12 +186,18 @@ async function publishRecording(file: string) {
|
||||
<!-- Recordings -->
|
||||
<div v-if="recordings.length" class="card space-y-4">
|
||||
<h2 class="font-semibold">Recordings</h2>
|
||||
<div v-if="podcasts.length">
|
||||
<label class="label" for="rec-target">Publish to podcast</label>
|
||||
<select id="rec-target" v-model="publishTarget" class="input">
|
||||
<option value="" disabled>Choose a podcast…</option>
|
||||
<option v-for="p in podcasts" :key="p.id" :value="p.id">{{ p.title }}</option>
|
||||
</select>
|
||||
<div v-if="podcasts.length" class="space-y-3">
|
||||
<div>
|
||||
<label class="label" for="rec-target">Publish to podcast</label>
|
||||
<select id="rec-target" v-model="publishTarget" class="input">
|
||||
<option value="" disabled>Choose a podcast…</option>
|
||||
<option v-for="p in podcasts" :key="p.id" :value="p.id">{{ p.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="rec-price">Price in sats (optional — leave blank for a free episode)</label>
|
||||
<input id="rec-price" v-model.number="publishPriceSats" type="number" min="1" step="1" class="input" placeholder="Free" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-white/50">
|
||||
Create a podcast (via <RouterLink class="text-orange-400 underline" to="/upload">Upload an episode</RouterLink>)
|
||||
|
||||
@@ -9,7 +9,7 @@ apiAddress: :9997
|
||||
|
||||
# ---- authentication ------------------------------------------------------
|
||||
authMethod: http
|
||||
authHTTPAddress: http://podsteadr:8095/api/mediamtx/auth
|
||||
authHTTPAddress: http://podsteadr-app:8095/api/mediamtx/auth
|
||||
authHTTPExclude:
|
||||
- action: api
|
||||
- action: metrics
|
||||
|
||||
@@ -21,7 +21,14 @@ vi.mock('../services/cashu.js', () => {
|
||||
return 'fake-proof-row-id';
|
||||
}
|
||||
async payout(lud16: string, amountSats: number) {
|
||||
return { preimage: `fake-preimage-for-${lud16}-${amountSats}` };
|
||||
return { preimage: `fake-preimage-for-${lud16}-${amountSats}`, paidSats: amountSats };
|
||||
}
|
||||
// Fake tokens are just "fake-token-<sats>" — real ones would be decoded and swapped with
|
||||
// the mint; here the "amount" is however many sats the test wrote into the string.
|
||||
async receiveToken(token: string) {
|
||||
const match = /^fake-token-(\d+)$/.exec(token);
|
||||
if (!match) throw new CashuTokenInvalidError(`could not redeem token: malformed test token "${token}"`);
|
||||
return { amountSats: Number(match[1]), rowId: 'fake-proof-row-id' };
|
||||
}
|
||||
}
|
||||
class CashuClientPool {
|
||||
@@ -31,7 +38,8 @@ vi.mock('../services/cashu.js', () => {
|
||||
}
|
||||
class CashuUnreachableError extends Error {}
|
||||
class CashuInsufficientFundsError extends Error {}
|
||||
return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError };
|
||||
class CashuTokenInvalidError extends Error {}
|
||||
return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError };
|
||||
});
|
||||
|
||||
const { buildApp } = await import('../app.js');
|
||||
@@ -209,6 +217,94 @@ describe('purchase flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('paying with a Cashu token', () => {
|
||||
// Isolated podcast/episode/producer — purchases here would otherwise credit the shared
|
||||
// producerPk's earnings and break the later "earnings" describe block's exact-total assertions.
|
||||
let tokenProducerPk: string;
|
||||
let tokenPodcastId: string;
|
||||
let tokenEpisodeId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const sk = generateSecretKey();
|
||||
tokenProducerPk = getPublicKey(sk);
|
||||
const cookie = await login(sk);
|
||||
|
||||
const podcastRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/podcasts',
|
||||
headers: { cookie },
|
||||
payload: { title: 'Token-Paid Show', lightning_address: 'tokenproducer@getalby.com' },
|
||||
});
|
||||
tokenPodcastId = podcastRes.json().id;
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } })));
|
||||
const episodeRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${tokenPodcastId}/episodes`,
|
||||
headers: { cookie },
|
||||
payload: { title: 'Token-Paid Ep', sha256: 'f'.repeat(64), size: 1000, mime: 'video/mp4', price_sats: 500 },
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
tokenEpisodeId = episodeRes.json().id;
|
||||
});
|
||||
|
||||
it('redeems a token worth exactly the price and unlocks the episode', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
const cookie = await login(sk);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
|
||||
headers: { cookie },
|
||||
payload: { source: 'producer', token: 'fake-token-500' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const purchase = res.json();
|
||||
expect(purchase.buyer_pubkey).toBe(pk);
|
||||
expect(purchase.seller_pubkey).toBe(tokenProducerPk);
|
||||
expect(purchase.amount_sats).toBe(500);
|
||||
});
|
||||
|
||||
it('treats a token worth more than the price as a tip, crediting the full amount', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const cookie = await login(sk);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
|
||||
headers: { cookie },
|
||||
payload: { source: 'producer', token: 'fake-token-600' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().amount_sats).toBe(600);
|
||||
});
|
||||
|
||||
it('rejects a token worth less than the price', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const cookie = await login(sk);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
|
||||
headers: { cookie },
|
||||
payload: { source: 'producer', token: 'fake-token-100' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/worth 100 sats/);
|
||||
});
|
||||
|
||||
it('rejects a token it cannot redeem', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const cookie = await login(sk);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
|
||||
headers: { cookie },
|
||||
payload: { source: 'producer', token: 'not-a-real-token' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/could not redeem token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resale', () => {
|
||||
const resellerMirrorUrl = 'https://mirror.example.com';
|
||||
let resellerQuoteId: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { checkBlob, BlossomUnreachableError } from '../services/blossom.js';
|
||||
import { CashuUnreachableError, CashuInsufficientFundsError } from '../services/cashu.js';
|
||||
import { CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError } from '../services/cashu.js';
|
||||
import { getEpisodeSources } from '../services/marketplace.js';
|
||||
import type { Earning, Episode, Podcast, Purchase, Reseller, User } from '../types.js';
|
||||
|
||||
@@ -21,6 +21,11 @@ const purchaseSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
});
|
||||
|
||||
const tokenPurchaseSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
const resellerSchema = z.object({
|
||||
download_url: z.string().url(),
|
||||
price_sats: z.number().int().positive(),
|
||||
@@ -188,6 +193,111 @@ export default async function marketplaceRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// Pay with a Cashu token directly instead of a Lightning invoice — no quote/confirm round
|
||||
// trip needed, since the token itself already represents settled value. Redeems the token
|
||||
// and finalizes the purchase (receipt, earnings split) in one step.
|
||||
app.post(
|
||||
'/api/podcasts/:id/episodes/:eid/purchase/token',
|
||||
{ preHandler: app.requireAuth },
|
||||
async (req, reply) => {
|
||||
const { id, eid } = req.params as { id: string; eid: string };
|
||||
const found = episodeAndPodcast(id, eid);
|
||||
if (!found) return reply.code(404).send({ error: 'episode not found' });
|
||||
const { episode, podcast } = found;
|
||||
if (!episode.price_sats) return reply.code(400).send({ error: 'this episode is free' });
|
||||
|
||||
const parsed = tokenPurchaseSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||
|
||||
const buyerPubkey = req.userPubkey!;
|
||||
if (buyerPubkey === podcast.owner_pubkey) {
|
||||
return reply.code(400).send({ error: 'you own this podcast' });
|
||||
}
|
||||
if (getPurchase.get(eid, buyerPubkey)) {
|
||||
return reply.code(400).send({ error: 'already purchased' });
|
||||
}
|
||||
|
||||
const source = parsed.data.source;
|
||||
let priceSats: number;
|
||||
if (source === 'producer') {
|
||||
priceSats = episode.price_sats;
|
||||
} else {
|
||||
const reseller = getReseller.get(eid, source) as Reseller | undefined;
|
||||
if (!reseller) return reply.code(404).send({ error: 'reseller not found' });
|
||||
priceSats = reseller.price_sats;
|
||||
}
|
||||
|
||||
const cashu = app.ctx.cashu.client(settings.all().cashu_mint_url);
|
||||
let received: { amountSats: number; rowId: string };
|
||||
try {
|
||||
received = await cashu.receiveToken(parsed.data.token);
|
||||
} catch (err) {
|
||||
if (err instanceof CashuTokenInvalidError) return reply.code(400).send({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
if (received.amountSats < priceSats) {
|
||||
return reply.code(400).send({
|
||||
error: `token is worth ${received.amountSats} sats, episode costs ${priceSats}`,
|
||||
});
|
||||
}
|
||||
// A token worth more than the price is treated as a tip — the seller gets the full
|
||||
// redeemed amount, same as an over-generous Lightning payment would.
|
||||
const amountSats = received.amountSats;
|
||||
|
||||
// Resolve seller(s) + split against current state, same as the invoice-confirm path.
|
||||
let sellerPubkey: string;
|
||||
let generation: number;
|
||||
let parentPurchaseId: string | undefined;
|
||||
let earningsRows: Array<{ pubkey: string; amountSats: number }>;
|
||||
if (source === 'producer') {
|
||||
sellerPubkey = podcast.owner_pubkey;
|
||||
generation = 0;
|
||||
earningsRows = [{ pubkey: sellerPubkey, amountSats }];
|
||||
} else {
|
||||
const reseller = getReseller.get(eid, source) as Reseller | undefined;
|
||||
const parentPurchase = getPurchase.get(eid, source) as Purchase | undefined;
|
||||
if (!reseller || !parentPurchase) {
|
||||
return reply.code(409).send({ error: 'reseller is no longer certified for this episode' });
|
||||
}
|
||||
sellerPubkey = source;
|
||||
generation = parentPurchase.generation + 1;
|
||||
parentPurchaseId = parentPurchase.id;
|
||||
const producerSats = Math.round((amountSats * podcast.resale_producer_share_pct) / 100);
|
||||
earningsRows = [
|
||||
{ pubkey: podcast.owner_pubkey, amountSats: producerSats },
|
||||
{ pubkey: sellerPubkey, amountSats: amountSats - producerSats },
|
||||
];
|
||||
}
|
||||
|
||||
const purchaseId = randomUUID();
|
||||
const receipt = await publisher.publishPurchaseReceipt(settings.all().relays, {
|
||||
purchaseId,
|
||||
episodeSha256: episode.sha256,
|
||||
buyerPubkey,
|
||||
sellerPubkey,
|
||||
amountSats,
|
||||
generation,
|
||||
parentPurchaseId,
|
||||
});
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO purchases (id, episode_id, buyer_pubkey, seller_pubkey, generation,
|
||||
amount_sats, receipt_event_id, receipt_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(purchaseId, eid, buyerPubkey, sellerPubkey, generation, amountSats,
|
||||
receipt.id, JSON.stringify(receipt), nowSecs());
|
||||
const insertEarning = db.prepare(
|
||||
'INSERT INTO earnings (id, purchase_id, pubkey, amount_sats) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
for (const e of earningsRows) insertEarning.run(randomUUID(), purchaseId, e.pubkey, e.amountSats);
|
||||
});
|
||||
tx();
|
||||
|
||||
return reply.code(201).send(getPurchase.get(eid, buyerPubkey) as Purchase);
|
||||
},
|
||||
);
|
||||
|
||||
// Producer + certified resellers for an episode, with a naive reputation signal (how many
|
||||
// sales that seller has made). Public and deliberately unauthenticated, including real
|
||||
// download URLs — this is an open catalog meant to be crawlable (by other podsteadr servers,
|
||||
@@ -327,6 +437,6 @@ export default async function marketplaceRoutes(app: FastifyInstance) {
|
||||
});
|
||||
tx();
|
||||
|
||||
return { paid_sats: totalSats, preimage: result.preimage };
|
||||
return { paid_sats: result.paidSats, preimage: result.preimage };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const publishRecordingSchema = z.object({
|
||||
podcast_id: z.string().uuid(),
|
||||
title: z.string().min(1).max(300),
|
||||
description: z.string().max(10000).default(''),
|
||||
price_sats: z.number().int().positive().nullish(),
|
||||
});
|
||||
|
||||
export default async function streamRoutes(app: FastifyInstance) {
|
||||
@@ -174,10 +175,10 @@ export default async function streamRoutes(app: FastifyInstance) {
|
||||
const eid = randomUUID();
|
||||
db.prepare(`
|
||||
INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url,
|
||||
enclosure_length, enclosure_type, duration_secs, source, pub_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?)
|
||||
enclosure_length, enclosure_type, duration_secs, source, price_sats, pub_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?, ?)
|
||||
`).run(eid, d.podcast_id, d.title, d.description, uploaded.sha256,
|
||||
`${publicBlossom}/${uploaded.sha256}.mp4`, uploaded.size, duration, nowSecs(), nowSecs());
|
||||
`${publicBlossom}/${uploaded.sha256}.mp4`, uploaded.size, duration, d.price_sats ?? null, nowSecs(), nowSecs());
|
||||
db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), d.podcast_id);
|
||||
return reply.code(201).send(
|
||||
db.prepare('SELECT * FROM episodes WHERE id = ?').get(eid) as Episode,
|
||||
|
||||
@@ -9,6 +9,7 @@ function nowSecs(): number {
|
||||
|
||||
export class CashuUnreachableError extends Error {}
|
||||
export class CashuInsufficientFundsError extends Error {}
|
||||
export class CashuTokenInvalidError extends Error {}
|
||||
|
||||
export interface MintQuote {
|
||||
quoteId: string;
|
||||
@@ -65,6 +66,26 @@ export class CashuMintClient {
|
||||
return this.storeProofs(proofs, amountSats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems an incoming Cashu token as payment — no invoice, no Lightning round-trip. The
|
||||
* token's proofs are swapped for the server's own (invalidating the sender's copy) and
|
||||
* custodied exactly like a Lightning-funded purchase. Only tokens from this same mint are
|
||||
* accepted — the mint's own swap call rejects proofs it didn't issue, so a wrong-mint token
|
||||
* fails here with whatever error the mint gives rather than a bespoke pre-check.
|
||||
*/
|
||||
async receiveToken(token: string): Promise<{ amountSats: number; rowId: string }> {
|
||||
await this.ensureLoaded();
|
||||
let proofs: Proof[];
|
||||
try {
|
||||
proofs = await this.wallet.ops.receive(token).run();
|
||||
} catch (err) {
|
||||
throw new CashuTokenInvalidError(`could not redeem token: ${(err as Error).message}`);
|
||||
}
|
||||
const amountSats = proofs.reduce((sum, p) => sum + p.amount.toNumber(), 0);
|
||||
const rowId = this.storeProofs(proofs, amountSats);
|
||||
return { amountSats, rowId };
|
||||
}
|
||||
|
||||
private storeProofs(proofs: Proof[], amountSats: number): string {
|
||||
const id = randomUUID();
|
||||
this.db
|
||||
@@ -83,14 +104,32 @@ export class CashuMintClient {
|
||||
|
||||
/**
|
||||
* Resolves a lud16 (lightning address) to a bolt11 invoice for `amountSats` via LNURL-pay
|
||||
* (LUD-16 / LUD-06), melts enough held proofs to pay it, and returns the payment preimage.
|
||||
* Any unspent overpaid-fee-reserve change proofs are re-stored for future payouts.
|
||||
* (LUD-16 / LUD-06), melts enough held proofs to pay it, and returns the payment preimage
|
||||
* plus however many sats actually went out (see below).
|
||||
*
|
||||
* The mint charges a small routing-fee reserve on top of the invoice amount, paid out of the
|
||||
* held balance — so requesting an invoice for the full nominal balance always needs slightly
|
||||
* *more* than that balance to melt. Left uncorrected, a balance that exactly matches what's
|
||||
* owed (the common case: proceeds of one sale, no cushion) can never be withdrawn at all. If
|
||||
* the first quote doesn't fit what's held, shrink the invoice request by the shortfall and
|
||||
* requote once — the payout is a few sats less than nominal, same as any other Lightning
|
||||
* withdrawal fee, rather than failing outright.
|
||||
*/
|
||||
async payout(lud16: string, amountSats: number): Promise<{ preimage: string | null }> {
|
||||
async payout(lud16: string, amountSats: number): Promise<{ preimage: string | null; paidSats: number }> {
|
||||
await this.ensureLoaded();
|
||||
const invoice = await this.resolveLnurlpInvoice(lud16, amountSats);
|
||||
const meltQuote = await this.wallet.createMeltQuoteBolt11(invoice);
|
||||
const needed = meltQuote.amount.add(meltQuote.fee_reserve).toNumber();
|
||||
const heldSats = this.totalHeldSats();
|
||||
|
||||
let requestSats = amountSats;
|
||||
let invoice = await this.resolveLnurlpInvoice(lud16, requestSats);
|
||||
let meltQuote = await this.wallet.createMeltQuoteBolt11(invoice);
|
||||
let needed = meltQuote.amount.add(meltQuote.fee_reserve).toNumber();
|
||||
|
||||
if (needed > heldSats) {
|
||||
requestSats = Math.max(1, amountSats - (needed - heldSats));
|
||||
invoice = await this.resolveLnurlpInvoice(lud16, requestSats);
|
||||
meltQuote = await this.wallet.createMeltQuoteBolt11(invoice);
|
||||
needed = meltQuote.amount.add(meltQuote.fee_reserve).toNumber();
|
||||
}
|
||||
|
||||
const unspent = this.db
|
||||
.prepare('SELECT * FROM cashu_proofs WHERE spent_at IS NULL ORDER BY created_at')
|
||||
@@ -104,7 +143,7 @@ export class CashuMintClient {
|
||||
}
|
||||
if (sum < needed) {
|
||||
throw new CashuInsufficientFundsError(
|
||||
`held ${sum} sats, need ${needed} sats to pay out ${amountSats} to ${lud16}`,
|
||||
`held ${sum} sats, need ${needed} sats to pay out ${requestSats} to ${lud16}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,7 +160,7 @@ export class CashuMintClient {
|
||||
});
|
||||
spendTx();
|
||||
|
||||
return { preimage: result.quote.payment_preimage };
|
||||
return { preimage: result.quote.payment_preimage, paidSats: requestSats };
|
||||
}
|
||||
|
||||
private async resolveLnurlpInvoice(lud16: string, amountSats: number): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user