YES! Let’s turn that spark into a shippable, photo‑first, Bitcoin‑powered messenger.

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)

  1. Install deps:

cd backend

npm i axios

  1. 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’;

  }

}

  1. 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!);

  1. 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

  1. Generate a random content key (AES‑GCM 256) for each asset.
  2. Encrypt the photo with this key on device.
  3. Upload the ciphertext (never the plaintext).
  4. Send a message that references the asset and its price in sats.

Unlock flow

  1. Viewer taps → app requests invoice for price_sats tied to asset_id.
  2. After payment.confirmed, server sends back the wrapped content key re‑encrypted to the viewer’s device public key.
  3. 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)

  1. Encrypt the file before upload (as above).
  2. 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. ⚡📸