Below is a clear, step‑by‑step “how to make it” guide—from running the starter to adding real Lightning, encrypted media, and E2EE chat.
Get the starter kit (already prepared for you):
⬇️ Download shuttersats-starter.zip
0) What you’re building (shorthand)
ShutterSats = Telegram feel + photographer‑centric UX + sats flows (paywalls, tips, subs).
Stack: React Native (Expo) app + Node/Express API → (Later) Matrix for E2EE chat, S3/R2 for encrypted media, Lightning for payments.
1) Run the demo today (proof the loop)
Backend
cd backend
cp .env.example .env
npm i
npm run dev
Mobile
cd mobile
npm i
npm run start
Open the Expo app, tap Open Demo Chat, then tap the locked photo → invoice → (demo auto‑settles) → unlock.
You now have the core UX loop end‑to‑end. 🎉
2) Replace mock Lightning with a real provider (the “powered by Bitcoin” part)
Option A — Fastest path:
LNbits
(server‑issued invoices)
- Install deps:
cd backend
npm i axios
- Create backend/src/lib/providers/lnbitsProvider.ts:
import axios from ‘axios’;
import { PaymentProvider, Invoice, InvoiceStatus } from ‘./paymentProvider.js’;
type CreateInput = { amountSats: number; memo?: string; assetId?: string; expiresInSec?: number };
export class LNbitsProvider implements PaymentProvider {
constructor(private baseUrl: string, private apiKey: string) {}
async createInvoice({ amountSats, memo, assetId, expiresInSec }: CreateInput): Promise<Invoice> {
const now = new Date();
const expiry = expiresInSec ?? 1800;
const { data } = await axios.post(
`${this.baseUrl}/api/v1/payments`,
{ out: false, amount: amountSats, memo, expiry },
{ headers: { ‘X-Api-Key’: this.apiKey } }
);
const inv: Invoice = {
id: data.payment_hash,
bolt11: data.payment_request,
amountSats,
status: ‘unpaid’,
memo,
assetId,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + expiry * 1000).toISOString(),
settledAt: null
};
return inv;
}
async getInvoiceStatus(id: string): Promise<InvoiceStatus> {
const { data } = await axios.get(`${this.baseUrl}/api/v1/payments/${id}`, {
headers: { ‘X-Api-Key’: this.apiKey }
});
return data.paid ? ‘paid’ : ‘unpaid’;
}
}
- Wire it in backend/src/routes/payments.ts:
// replace FakePaymentProvider import with:
import { LNbitsProvider } from ‘../lib/providers/lnbitsProvider.js’;
// and swap provider:
const provider = new LNbitsProvider(process.env.LNBITS_URL!, process.env.LNBITS_API_KEY!);
- Env
# backend/.env
AUTO_PAY=false
LNBITS_URL=https://<your-lnbits-host>
LNBITS_API_KEY=xxxxxxxxxxxxxxxx
Option B —
LND/CLN
(own node)
- Use gRPC/REST to create invoices and poll their status; store payment_hash as the Invoice.id.
- Keep the same PaymentProvider interface—just switch implementation.
- Prefer non‑custodial clients later (LDK/Breez SDK inside the mobile app) so you never hold funds.
3) Make the paywall real (unlock keys, not just UI)
Right now the demo toggles a flag. In production you want cryptographic paywalls:
Client flow
- Generate a random content key (AES‑GCM 256) for each asset.
- Encrypt the photo with this key on device.
- Upload the ciphertext (never the plaintext).
- Send a message that references the asset and its price in sats.
Unlock flow
- Viewer taps → app requests invoice for price_sats tied to asset_id.
- After payment.confirmed, server sends back the wrapped content key re‑encrypted to the viewer’s device public key.
- Client decrypts the wrapped key, then decrypts the photo locally.
Client‑side crypto (Web Crypto API, React Native)
// generate key
const key = await crypto.subtle.generateKey({ name: ‘AES-GCM’, length: 256 }, true, [‘encrypt’,’decrypt’]);
const iv = crypto.getRandomValues(new Uint8Array(12));
// encrypt ArrayBuffer `bytes`
const enc = await crypto.subtle.encrypt({ name: ‘AES-GCM’, iv }, key, bytes);
Store: { iv, ciphertext, sha256, mime }.
Never ship content keys unencrypted; wrap them per‑recipient.
4) Encrypted uploads (replace the upload stub)
- Encrypt the file before upload (as above).
- Ask backend for a pre‑signed URL; upload direct to S3/R2.
Server snippet (S3 presign)
// npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from ‘@aws-sdk/client-s3’;
import { getSignedUrl } from ‘@aws-sdk/s3-request-presigner’;
import { v4 as uuid } from ‘uuid’;
const s3 = new S3Client({ region: process.env.AWS_REGION });
router.post(‘/media/upload-init’, async (req, res) => {
const { sha256, bytes, mime, fileName } = req.body;
const key = `media/${uuid()}-${fileName}`;
const cmd = new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: mime, ContentLength: bytes });
const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 600 });
// Persist asset metadata (sha256, bytes, mime, key, owner_id, etc.)
res.json({ assetId: uuid(), uploadUrl, storageKey: key });
});
5) Real chat & E2EE (Matrix track)
Use Matrix to avoid writing your own E2EE sync engine.
High level
- Spin up Synapse (or Dendrite).
- In the app, use matrix-js-sdk or matrix-rust-sdk to login, create rooms, and send messages.
- Store only ciphertext on the server; media blobs stay encrypted in S3/R2.
- Map Matrix room events to your message/media_asset records for paywalls.
Message model
- message.kind: text, image, etc.
- message.media_asset_id: links the encrypted blob.
- message.price_sats: 0 for public, >0 for paywalled.
6) Photographer‑first UI polish (make it sing)
- Big previews, buttery scroll, pinch‑zoom, ICC‑aware thumbnails (generate with libvips).
- EXIF/IPTC preserved; show shutter/aperture/ISO overlays.
- Rights flag per asset (Editorial/Personal/Commercial).
- Watermark toggle for public channels.
- Client galleries with “selects” and comments right in chat threads.
7) Profiles, tips, and Lightning Address
- Add lightning_address to user profile (e.g., name@domain).
- Tip button → LNURL‑pay directly to creator (non‑custodial—no custody risk).
- Track totals client‑side; don’t store user secrets.
8) Deploy the MVP
- Backend: Fly.io, Railway, or your cloud of choice.
- DB: Neon/Supabase Postgres (schema provided in /docs/schema.sql).
- Storage/CDN: S3 or Cloudflare R2 + Cloudflare CDN.
- Domain & TLS: any managed cert (Cloudflare makes it easy).
- Env: set LNBITS_* OR LND/CLN creds securely, disable any mock routes.
9) Hardening & safety
- Rate limits, auth throttles, invite‑only for early communities.
- Abuse/CSAM checks only on public content; private rooms remain zero‑knowledge.
- Backups & key recovery (export seed / passphrase; educate users).
10) Milestone checklist (print this!)
- Run starter (mobile + backend) locally
- Swap in real Lightning provider (LNbits or LND/CLN)
- Encrypt media on device; presigned uploads; thumbnails via libvips
- Implement key‑wrapping paywall (unlock on payment)
- Integrate Matrix for E2EE chat
- Profile tips (LNURL‑pay), galleries & selects
- Deploy (DB, bucket, CDN, domain) + observability & rate limits
- Beta with photographers; iterate on UX & pricing
Pro tips
- Stay non‑custodial wherever possible; it’s cleaner legally and aligns with Bitcoin ethos.
- Minimize metadata: store hashes, sizes, and encrypted blobs—no plaintext content.
- Test big files (RAWs): resumable uploads, background retries, offline cache.
Want me to push it further?
I can generate:
- A ready‑to‑use LNbits provider in your backend folder,
- A Matrix-backed chat screen in the mobile app,
- Or the S3 presign route + client encryption hooks.
You’ve got this—let’s make photographers smile and sats sparkle. ⚡📸