-
-
+
+
+
+
+
+
+
+
+
Create a podcast (via Upload an episode)
diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml
index 4595a22..ceafde2 100644
--- a/mediamtx/mediamtx.yml
+++ b/mediamtx/mediamtx.yml
@@ -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
diff --git a/server/src/routes/marketplace.test.ts b/server/src/routes/marketplace.test.ts
index aea1a8d..2b05f70 100644
--- a/server/src/routes/marketplace.test.ts
+++ b/server/src/routes/marketplace.test.ts
@@ -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-" — 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;
diff --git a/server/src/routes/marketplace.ts b/server/src/routes/marketplace.ts
index 5e7c5f1..9179341 100644
--- a/server/src/routes/marketplace.ts
+++ b/server/src/routes/marketplace.ts
@@ -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 };
});
}
diff --git a/server/src/routes/streams.ts b/server/src/routes/streams.ts
index ab1a1e6..8dd4730 100644
--- a/server/src/routes/streams.ts
+++ b/server/src/routes/streams.ts
@@ -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,
diff --git a/server/src/services/cashu.ts b/server/src/services/cashu.ts
index 69f6dd7..dc18984 100644
--- a/server/src/services/cashu.ts
+++ b/server/src/services/cashu.ts
@@ -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 {