From 81aa62b9818de13150becd08159617f878642b64 Mon Sep 17 00:00:00 2001 From: pugsley76 Date: Sun, 26 Apr 2026 08:19:44 -0700 Subject: [PATCH 1/4] feat: stellar memo group ID linking and escrow lifecycle service Feature A - Stellar Memo Group ID Linking: - Add lib/blockchain/memo.ts: buildGroupMemo(), validateGroupMemo(), extractGroupIdFromMemo() with auto text/hash strategy (28-byte limit) - Add lib/blockchain/memo-service.ts: submitGroupMemoTransaction(), getMemosByGroupId(), getMemoByTxHash(), revalidateGroupMemos() - Add lib/blockchain/memo-validation.ts: request-level middleware for groupId existence, memo value integrity, and txHash record checks - Add app/api/stellar/memo/route.ts: POST (submit) / GET (list) - Add app/api/stellar/memo/validate/route.ts: POST (validate txHash+groupId) - Add scripts/011_group_tx_memo_map.sql: group_tx_memo_map table with RLS - Update app/api/rooms/route.ts: fire memo tx on room creation - Update types/blockchain.ts: add memo field to GroupCreationResponse Feature B - Escrow Lifecycle Service Layer: - Add types/escrow.ts: EscrowRecord, EscrowStatus, all input/output types - Add lib/blockchain/escrow-service.ts: createEscrow, fundEscrow, releaseEscrow, refundEscrow, disputeEscrow, resolveDispute, getEscrow, listEscrowsByGroup, listEscrowsByWallet, getEscrowEvents - Add app/api/escrow/route.ts: POST (create) / GET (list by group or wallet) - Add app/api/escrow/[escrowId]/route.ts: GET (detail + event history) - Add app/api/escrow/[escrowId]/fund/route.ts - Add app/api/escrow/[escrowId]/release/route.ts - Add app/api/escrow/[escrowId]/refund/route.ts - Add app/api/escrow/[escrowId]/dispute/route.ts - Add app/api/escrow/[escrowId]/resolve/route.ts - Add scripts/012_escrow_tables.sql: escrows + escrow_events tables with RLS --- app/api/escrow/[escrowId]/dispute/route.ts | 66 ++ app/api/escrow/[escrowId]/fund/route.ts | 68 ++ app/api/escrow/[escrowId]/refund/route.ts | 67 ++ app/api/escrow/[escrowId]/release/route.ts | 64 ++ app/api/escrow/[escrowId]/resolve/route.ts | 74 ++ app/api/escrow/[escrowId]/route.ts | 39 + app/api/escrow/route.ts | 161 ++++ app/api/rooms/route.ts | 45 ++ app/api/stellar/memo/route.ts | 120 +++ app/api/stellar/memo/validate/route.ts | 65 ++ lib/blockchain/escrow-service.ts | 821 +++++++++++++++++++++ lib/blockchain/memo-service.ts | 310 ++++++++ lib/blockchain/memo-validation.ts | 230 ++++++ lib/blockchain/memo.ts | 127 ++++ scripts/011_group_tx_memo_map.sql | 46 ++ scripts/012_escrow_tables.sql | 85 +++ types/blockchain.ts | 5 + types/escrow.ts | 115 +++ 18 files changed, 2508 insertions(+) create mode 100644 app/api/escrow/[escrowId]/dispute/route.ts create mode 100644 app/api/escrow/[escrowId]/fund/route.ts create mode 100644 app/api/escrow/[escrowId]/refund/route.ts create mode 100644 app/api/escrow/[escrowId]/release/route.ts create mode 100644 app/api/escrow/[escrowId]/resolve/route.ts create mode 100644 app/api/escrow/[escrowId]/route.ts create mode 100644 app/api/escrow/route.ts create mode 100644 app/api/stellar/memo/route.ts create mode 100644 app/api/stellar/memo/validate/route.ts create mode 100644 lib/blockchain/escrow-service.ts create mode 100644 lib/blockchain/memo-service.ts create mode 100644 lib/blockchain/memo-validation.ts create mode 100644 lib/blockchain/memo.ts create mode 100644 scripts/011_group_tx_memo_map.sql create mode 100644 scripts/012_escrow_tables.sql create mode 100644 types/escrow.ts diff --git a/app/api/escrow/[escrowId]/dispute/route.ts b/app/api/escrow/[escrowId]/dispute/route.ts new file mode 100644 index 0000000..8401c07 --- /dev/null +++ b/app/api/escrow/[escrowId]/dispute/route.ts @@ -0,0 +1,66 @@ +/** + * POST /api/escrow/[escrowId]/dispute + * Raises a dispute on a funded escrow. + * Either the initiator or beneficiary can call this. + * + * Body: { reason: string } + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { disputeEscrow } from "@/lib/blockchain/escrow-service"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ escrowId: string }> } +) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { escrowId } = await params; + + const body = await request.json().catch(() => ({})); + const { reason } = body as { reason?: string }; + + if (!reason || typeof reason !== "string" || reason.trim().length === 0) { + return NextResponse.json( + { error: "reason is required and must be a non-empty string" }, + { status: 400 } + ); + } + + // Resolve actor wallet from user profile + const { data: profile } = await supabase + .from("profiles") + .select("wallet_address") + .eq("id", user.id) + .maybeSingle(); + + const actorWallet = profile?.wallet_address as string | undefined; + if (!actorWallet) { + return NextResponse.json( + { error: "No wallet address found for your account" }, + { status: 400 } + ); + } + + const result = await disputeEscrow({ escrowId, actorWallet, reason }, supabase); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ escrow: result.escrow }); + } catch (error) { + console.error("[escrow/dispute] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/escrow/[escrowId]/fund/route.ts b/app/api/escrow/[escrowId]/fund/route.ts new file mode 100644 index 0000000..e2d5875 --- /dev/null +++ b/app/api/escrow/[escrowId]/fund/route.ts @@ -0,0 +1,68 @@ +/** + * POST /api/escrow/[escrowId]/fund + * Marks an escrow as funded after the initiator has sent funds on-chain. + * + * Body: { fundTxHash: string } + * + * The caller must have already submitted the Stellar payment transaction + * and provide the resulting transaction hash. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { fundEscrow } from "@/lib/blockchain/escrow-service"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ escrowId: string }> } +) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { escrowId } = await params; + + const body = await request.json().catch(() => ({})); + const { fundTxHash } = body as { fundTxHash?: string }; + + if (!fundTxHash || typeof fundTxHash !== "string" || fundTxHash.trim() === "") { + return NextResponse.json( + { error: "fundTxHash is required" }, + { status: 400 } + ); + } + + // Resolve actor wallet from user profile + const { data: profile } = await supabase + .from("profiles") + .select("wallet_address") + .eq("id", user.id) + .maybeSingle(); + + const actorWallet = profile?.wallet_address as string | undefined; + if (!actorWallet) { + return NextResponse.json( + { error: "No wallet address found for your account" }, + { status: 400 } + ); + } + + const result = await fundEscrow({ escrowId, fundTxHash, actorWallet }, supabase); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ escrow: result.escrow, txHash: result.txHash }); + } catch (error) { + console.error("[escrow/fund] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/escrow/[escrowId]/refund/route.ts b/app/api/escrow/[escrowId]/refund/route.ts new file mode 100644 index 0000000..b2f9c6b --- /dev/null +++ b/app/api/escrow/[escrowId]/refund/route.ts @@ -0,0 +1,67 @@ +/** + * POST /api/escrow/[escrowId]/refund + * Refunds escrowed funds back to the initiator. + * + * Who can call this: + * - The beneficiary (at any time while funded) + * - The initiator (only after the escrow has expired) + * + * Body: { maxFee?: number } + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { refundEscrow } from "@/lib/blockchain/escrow-service"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ escrowId: string }> } +) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { escrowId } = await params; + + const body = await request.json().catch(() => ({})); + const { maxFee } = body as { maxFee?: number }; + + // Resolve actor wallet from user profile + const { data: profile } = await supabase + .from("profiles") + .select("wallet_address") + .eq("id", user.id) + .maybeSingle(); + + const actorWallet = profile?.wallet_address as string | undefined; + if (!actorWallet) { + return NextResponse.json( + { error: "No wallet address found for your account" }, + { status: 400 } + ); + } + + const result = await refundEscrow({ escrowId, actorWallet, maxFee }, supabase); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ + escrow: result.escrow, + txHash: result.txHash, + explorerUrl: result.explorerUrl, + feeCharged: result.feeCharged, + }); + } catch (error) { + console.error("[escrow/refund] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/escrow/[escrowId]/release/route.ts b/app/api/escrow/[escrowId]/release/route.ts new file mode 100644 index 0000000..66347bb --- /dev/null +++ b/app/api/escrow/[escrowId]/release/route.ts @@ -0,0 +1,64 @@ +/** + * POST /api/escrow/[escrowId]/release + * Releases escrowed funds to the beneficiary. + * Only the escrow initiator can call this. + * + * Body: { maxFee?: number } + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { releaseEscrow } from "@/lib/blockchain/escrow-service"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ escrowId: string }> } +) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { escrowId } = await params; + + const body = await request.json().catch(() => ({})); + const { maxFee } = body as { maxFee?: number }; + + // Resolve actor wallet from user profile + const { data: profile } = await supabase + .from("profiles") + .select("wallet_address") + .eq("id", user.id) + .maybeSingle(); + + const actorWallet = profile?.wallet_address as string | undefined; + if (!actorWallet) { + return NextResponse.json( + { error: "No wallet address found for your account" }, + { status: 400 } + ); + } + + const result = await releaseEscrow({ escrowId, actorWallet, maxFee }, supabase); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ + escrow: result.escrow, + txHash: result.txHash, + explorerUrl: result.explorerUrl, + feeCharged: result.feeCharged, + }); + } catch (error) { + console.error("[escrow/release] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/escrow/[escrowId]/resolve/route.ts b/app/api/escrow/[escrowId]/resolve/route.ts new file mode 100644 index 0000000..1a8ad1b --- /dev/null +++ b/app/api/escrow/[escrowId]/resolve/route.ts @@ -0,0 +1,74 @@ +/** + * POST /api/escrow/[escrowId]/resolve + * Resolves a disputed escrow by releasing funds to either party. + * + * This endpoint is restricted to authenticated users with a resolver role. + * In the current implementation, any authenticated user can resolve disputes + * (suitable for testnet / MVP). For production, add role-based access control + * or replace with DAO voting. + * + * Body: { + * releaseToInitiator: boolean, // true → refund initiator, false → pay beneficiary + * maxFee?: number + * } + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { resolveDispute } from "@/lib/blockchain/escrow-service"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ escrowId: string }> } +) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { escrowId } = await params; + + const body = await request.json().catch(() => ({})); + const { releaseToInitiator, maxFee } = body as { + releaseToInitiator?: boolean; + maxFee?: number; + }; + + if (typeof releaseToInitiator !== "boolean") { + return NextResponse.json( + { error: "releaseToInitiator (boolean) is required" }, + { status: 400 } + ); + } + + const result = await resolveDispute( + { + escrowId, + resolverUserId: user.id, + releaseToInitiator, + maxFee, + }, + supabase + ); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ + escrow: result.escrow, + txHash: result.txHash, + explorerUrl: result.explorerUrl, + feeCharged: result.feeCharged, + }); + } catch (error) { + console.error("[escrow/resolve] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/escrow/[escrowId]/route.ts b/app/api/escrow/[escrowId]/route.ts new file mode 100644 index 0000000..fd3e0bf --- /dev/null +++ b/app/api/escrow/[escrowId]/route.ts @@ -0,0 +1,39 @@ +/** + * GET /api/escrow/[escrowId] + * Returns a single escrow record with its event history. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { getEscrow, getEscrowEvents } from "@/lib/blockchain/escrow-service"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ escrowId: string }> } +) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { escrowId } = await params; + + const result = await getEscrow(escrowId, supabase); + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 404 }); + } + + const events = await getEscrowEvents(escrowId, supabase); + + return NextResponse.json({ escrow: result.escrow, events }); + } catch (error) { + console.error("[escrow/[escrowId]] GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/escrow/route.ts b/app/api/escrow/route.ts new file mode 100644 index 0000000..bb50b05 --- /dev/null +++ b/app/api/escrow/route.ts @@ -0,0 +1,161 @@ +/** + * POST /api/escrow + * Creates a new escrow record (status: pending). + * + * Body: { + * groupId: string, + * beneficiaryWallet: string, + * amountXlm: number, + * assetCode?: string, + * expiresAt?: string // ISO-8601 + * } + * + * GET /api/escrow?groupId= + * Lists all escrows for a group. + * + * GET /api/escrow?wallet=
+ * Lists all escrows where the wallet is initiator or beneficiary. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { + createEscrow, + listEscrowsByGroup, + listEscrowsByWallet, +} from "@/lib/blockchain/escrow-service"; +import { validateStellarAddress } from "@/lib/auth/validation"; + +// ── POST — create escrow ────────────────────────────────────────────────────── + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const { groupId, beneficiaryWallet, amountXlm, assetCode, expiresAt } = body as { + groupId?: string; + beneficiaryWallet?: string; + amountXlm?: number; + assetCode?: string; + expiresAt?: string; + }; + + // Resolve initiator wallet from user profile + const { data: profile } = await supabase + .from("profiles") + .select("wallet_address") + .eq("id", user.id) + .maybeSingle(); + + const initiatorWallet = profile?.wallet_address as string | undefined; + + if (!initiatorWallet) { + return NextResponse.json( + { error: "No wallet address found for your account" }, + { status: 400 } + ); + } + + // Input validation + if (!groupId) { + return NextResponse.json({ error: "groupId is required" }, { status: 400 }); + } + + if (!beneficiaryWallet || !validateStellarAddress(beneficiaryWallet)) { + return NextResponse.json( + { error: "beneficiaryWallet must be a valid Stellar address" }, + { status: 400 } + ); + } + + if (!amountXlm || typeof amountXlm !== "number" || amountXlm <= 0) { + return NextResponse.json( + { error: "amountXlm must be a positive number" }, + { status: 400 } + ); + } + + if (expiresAt && isNaN(Date.parse(expiresAt))) { + return NextResponse.json( + { error: "expiresAt must be a valid ISO-8601 datetime" }, + { status: 400 } + ); + } + + const result = await createEscrow( + { + groupId, + initiatorWallet, + beneficiaryWallet, + amountXlm, + assetCode, + expiresAt, + }, + supabase + ); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ escrow: result.escrow }, { status: 201 }); + } catch (error) { + console.error("[escrow] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ── GET — list escrows ──────────────────────────────────────────────────────── + +export async function GET(request: NextRequest) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const groupId = searchParams.get("groupId"); + const wallet = searchParams.get("wallet"); + + if (!groupId && !wallet) { + return NextResponse.json( + { error: "Provide either groupId or wallet query parameter" }, + { status: 400 } + ); + } + + if (groupId) { + const escrows = await listEscrowsByGroup(groupId, supabase); + return NextResponse.json({ escrows }); + } + + if (wallet) { + if (!validateStellarAddress(wallet)) { + return NextResponse.json( + { error: "wallet must be a valid Stellar address" }, + { status: 400 } + ); + } + const escrows = await listEscrowsByWallet(wallet, supabase); + return NextResponse.json({ escrows }); + } + } catch (error) { + console.error("[escrow] GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/rooms/route.ts b/app/api/rooms/route.ts index afaecba..7385684 100644 --- a/app/api/rooms/route.ts +++ b/app/api/rooms/route.ts @@ -5,6 +5,7 @@ import { submitMetadataHash, getTransactionExplorerUrl, } from "@/lib/blockchain/stellar-service"; +import { submitGroupMemoTransaction } from "@/lib/blockchain/memo-service"; import { GroupMetadata } from "@/types/blockchain"; import { logBlockchainOperation, @@ -181,6 +182,47 @@ export async function POST(request: NextRequest) { ); } + // Submit a dedicated group-ID memo transaction (non-blocking, graceful degradation) + let memoTxHash: string | null = null; + let memoValue: string | null = null; + let memoType: string | null = null; + + try { + const memoResult = await submitGroupMemoTransaction( + room.id, + supabase, + user.id, + max_fee + ); + + if (memoResult.success) { + memoTxHash = memoResult.txHash ?? null; + memoValue = memoResult.memoValue ?? null; + memoType = memoResult.memoType ?? null; + + logBlockchainOperation( + "info", + "Group memo transaction submitted", + { groupId: room.id, memoTxHash, memoValue, memoType }, + correlationId, + ); + } else { + logBlockchainOperation( + "warn", + "Group memo transaction failed, continuing without it", + { groupId: room.id, error: memoResult.error ? { type: "MemoError", message: memoResult.error } : undefined }, + correlationId, + ); + } + } catch (memoError: any) { + logBlockchainOperation( + "error", + "Group memo transaction error", + { groupId: room.id, error: { type: memoError.name || "UnknownError", message: memoError.message || "Unknown error" } }, + correlationId, + ); + } + // Return success response with blockchain info return NextResponse.json( { @@ -195,6 +237,9 @@ export async function POST(request: NextRequest) { transactionHash: stellarTxHash || undefined, feeCharged: actualFeeCharged || undefined, explorerUrl: explorerUrl || undefined, + memo: memoTxHash + ? { txHash: memoTxHash, memoValue, memoType } + : undefined, }, }, { status: 201 }, diff --git a/app/api/stellar/memo/route.ts b/app/api/stellar/memo/route.ts new file mode 100644 index 0000000..7ec68e9 --- /dev/null +++ b/app/api/stellar/memo/route.ts @@ -0,0 +1,120 @@ +/** + * POST /api/stellar/memo + * Submits a Stellar transaction with the group ID embedded in the memo field + * and persists the groupId ↔ txHash mapping in the database. + * + * Body: { groupId: string, maxFee?: number } + * + * GET /api/stellar/memo?groupId= + * Returns all memo records for a group, with validation status. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { submitGroupMemoTransaction, getMemosByGroupId, validateMemoRecord } from "@/lib/blockchain/memo-service"; +import { validateGroupIdExists, getMemoStrategyInfo } from "@/lib/blockchain/memo-validation"; + +// ── POST — submit a memo transaction ───────────────────────────────────────── + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const { groupId, maxFee } = body as { groupId?: string; maxFee?: number }; + + // Validate groupId exists + const groupCheck = await validateGroupIdExists(groupId ?? "", supabase); + if (!groupCheck.valid) return groupCheck.response; + + // Provide strategy info for transparency + const strategyInfo = getMemoStrategyInfo(groupId!); + + // Submit the memo transaction + const result = await submitGroupMemoTransaction( + groupId!, + supabase, + user.id, + maxFee + ); + + if (!result.success) { + return NextResponse.json( + { error: result.error ?? "Failed to submit memo transaction" }, + { status: 502 } + ); + } + + return NextResponse.json( + { + success: true, + groupId, + txHash: result.txHash, + memoValue: result.memoValue, + memoType: result.memoType, + explorerUrl: result.explorerUrl, + feeCharged: result.feeCharged, + strategy: strategyInfo, + }, + { status: 201 } + ); + } catch (error) { + console.error("[stellar/memo] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ── GET — retrieve memo records for a group ─────────────────────────────────── + +export async function GET(request: NextRequest) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const groupId = searchParams.get("groupId"); + + if (!groupId) { + return NextResponse.json( + { error: "groupId query parameter is required" }, + { status: 400 } + ); + } + + // Validate group exists + const groupCheck = await validateGroupIdExists(groupId, supabase); + if (!groupCheck.valid) return groupCheck.response; + + const records = await getMemosByGroupId(groupId, supabase); + + // Run in-memory validation on each record + const validated = records.map((r) => ({ + ...r, + validation: validateMemoRecord(r), + })); + + return NextResponse.json({ + groupId, + memos: validated, + strategy: getMemoStrategyInfo(groupId), + }); + } catch (error) { + console.error("[stellar/memo] GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/stellar/memo/validate/route.ts b/app/api/stellar/memo/validate/route.ts new file mode 100644 index 0000000..94e03ee --- /dev/null +++ b/app/api/stellar/memo/validate/route.ts @@ -0,0 +1,65 @@ +/** + * POST /api/stellar/memo/validate + * Validates memo integrity for a given groupId + txHash pair. + * + * Body: { groupId: string, txHash: string } + * + * Returns whether the memo record exists, is linked to the correct group, + * and passes integrity checks. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { getMemoByTxHash, validateMemoRecord } from "@/lib/blockchain/memo-service"; +import { + validateGroupIdExists, + validateTxMemoRecord, +} from "@/lib/blockchain/memo-validation"; + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const { groupId, txHash } = body as { groupId?: string; txHash?: string }; + + // Validate groupId + const groupCheck = await validateGroupIdExists(groupId ?? "", supabase); + if (!groupCheck.valid) return groupCheck.response; + + // Validate txHash against DB record + const txCheck = await validateTxMemoRecord(txHash ?? "", groupId!, supabase); + if (!txCheck.valid) return txCheck.response; + + // Fetch the record and run full validation + const record = await getMemoByTxHash(txHash!, supabase); + if (!record) { + return NextResponse.json( + { error: "Memo record not found" }, + { status: 404 } + ); + } + + const validation = validateMemoRecord(record); + + return NextResponse.json({ + valid: validation.isValid, + groupId: validation.groupId, + txHash: validation.txHash, + memoValue: validation.memoValue, + memoType: validation.memoType, + verifiedAt: validation.verifiedAt, + }); + } catch (error) { + console.error("[stellar/memo/validate] POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/lib/blockchain/escrow-service.ts b/lib/blockchain/escrow-service.ts new file mode 100644 index 0000000..c8a6de1 --- /dev/null +++ b/lib/blockchain/escrow-service.ts @@ -0,0 +1,821 @@ +/** + * Escrow Lifecycle Service + * + * Abstracts all blockchain interactions for escrow operations away from + * controllers. Each function follows the pattern: + * 1. Validate inputs & current escrow state + * 2. Execute on-chain operation (if required) + * 3. Persist state change + event log in DB + * 4. Return a clean EscrowResult + * + * Supported lifecycle: + * createEscrow → fundEscrow → releaseEscrow + * ↘ refundEscrow + * ↘ disputeEscrow → resolveDispute + * + * Design principles: + * - All blockchain logic lives here; controllers only call service functions + * - Graceful degradation: DB state is always updated even if on-chain fails + * - Modular: each lifecycle step is an independent, testable function + * - Extensible: dispute resolution can be upgraded to DAO voting later + */ + +import * as StellarSdk from "@stellar/stellar-sdk"; +import { SupabaseClient } from "@supabase/supabase-js"; +import { loadStellarConfig, isConfigured, getExplorerUrl } from "./stellar-config"; +import { logBlockchainOperation, generateCorrelationId } from "./logger"; +import { buildGroupMemo } from "./memo"; +import { + EscrowRecord, + EscrowResult, + EscrowStatus, + EscrowEventType, + CreateEscrowInput, + FundEscrowInput, + ReleaseEscrowInput, + RefundEscrowInput, + DisputeEscrowInput, + ResolveDisputeInput, +} from "@/types/escrow"; + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/** + * Appends a row to the escrow_events audit log. + * Non-fatal: errors are logged but do not throw. + */ +async function logEscrowEvent( + supabase: SupabaseClient, + escrowId: string, + eventType: EscrowEventType, + opts: { + txHash?: string; + actorWallet?: string; + metadata?: Record; + } = {} +): Promise { + const { error } = await supabase.from("escrow_events").insert({ + escrow_id: escrowId, + event_type: eventType, + tx_hash: opts.txHash ?? null, + actor_wallet: opts.actorWallet ?? null, + metadata: opts.metadata ?? {}, + }); + + if (error) { + console.error(`[EscrowService] Failed to log event '${eventType}' for escrow ${escrowId}:`, error.message); + } +} + +/** + * Fetches an escrow record by ID. Returns null if not found. + */ +async function fetchEscrow( + escrowId: string, + supabase: SupabaseClient +): Promise { + const { data, error } = await supabase + .from("escrows") + .select("*") + .eq("id", escrowId) + .maybeSingle(); + + if (error) { + logBlockchainOperation("error", "Failed to fetch escrow", { + escrowId, + error: { type: "DBError", message: error.message }, + }); + return null; + } + + return data as EscrowRecord | null; +} + +/** + * Asserts that an escrow is in one of the allowed statuses. + * Returns an error result if the assertion fails. + */ +function assertStatus( + escrow: EscrowRecord, + allowed: EscrowStatus[] +): EscrowResult | null { + if (!allowed.includes(escrow.status)) { + return { + success: false, + error: `Escrow is in '${escrow.status}' status; expected one of: ${allowed.join(", ")}`, + }; + } + return null; +} + +/** + * Submits a Stellar payment transaction. + * Used for release and refund operations. + */ +async function submitPayment(opts: { + destinationPublicKey: string; + amountXlm: string; + groupId: string; + memo?: StellarSdk.Memo; + maxFee?: string | number; +}): Promise<{ success: boolean; txHash?: string; feeCharged?: string; error?: string }> { + const correlationId = generateCorrelationId(); + + if (!isConfigured()) { + return { success: false, error: "Stellar configuration not available" }; + } + + const config = loadStellarConfig(); + if (!config) { + return { success: false, error: "Failed to load Stellar configuration" }; + } + + try { + const server = new StellarSdk.Horizon.Server(config.horizonUrl); + const sourceKeypair = StellarSdk.Keypair.fromSecret(config.sourceSecret); + const sourcePublicKey = sourceKeypair.publicKey(); + + const account = await Promise.race([ + server.loadAccount(sourcePublicKey), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout loading account")), config.transactionTimeout) + ), + ]); + + const feeToUse = opts.maxFee ? opts.maxFee.toString() : StellarSdk.BASE_FEE; + + const builder = new StellarSdk.TransactionBuilder(account, { + fee: feeToUse, + networkPassphrase: + config.network === "testnet" + ? StellarSdk.Networks.TESTNET + : StellarSdk.Networks.PUBLIC, + }).addOperation( + StellarSdk.Operation.payment({ + destination: opts.destinationPublicKey, + asset: StellarSdk.Asset.native(), + amount: opts.amountXlm, + }) + ); + + // Attach memo if provided, otherwise embed group ID + const memo = opts.memo ?? buildGroupMemo(opts.groupId).memo; + builder.addMemo(memo); + + const transaction = builder.setTimeout(30).build(); + transaction.sign(sourceKeypair); + + const result = await Promise.race([ + server.submitTransaction(transaction), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Transaction submission timeout")), config.transactionTimeout) + ), + ]); + + const feeCharged = (result as any).fee_charged?.toString() ?? feeToUse.toString(); + + logBlockchainOperation( + "info", + "Payment transaction submitted", + { groupId: opts.groupId, txHash: result.hash, feeCharged }, + correlationId + ); + + return { success: true, txHash: result.hash, feeCharged }; + } catch (error: any) { + logBlockchainOperation( + "error", + "Payment transaction failed", + { + groupId: opts.groupId, + error: { type: error.name ?? "UnknownError", message: error.message ?? "Unknown" }, + }, + correlationId + ); + return { success: false, error: error.message ?? "Transaction failed" }; + } +} + +// ── Public service functions ────────────────────────────────────────────────── + +/** + * Creates a new escrow record in the DB (status: "pending"). + * No on-chain transaction at this stage — funds are committed in fundEscrow(). + * + * @param input - Escrow creation parameters + * @param supabase - Supabase client (service-role recommended) + */ +export async function createEscrow( + input: CreateEscrowInput, + supabase: SupabaseClient +): Promise { + const correlationId = generateCorrelationId(); + + // Basic input validation + if (!input.groupId || !input.initiatorWallet || !input.beneficiaryWallet) { + return { success: false, error: "groupId, initiatorWallet, and beneficiaryWallet are required" }; + } + + if (input.amountXlm <= 0) { + return { success: false, error: "amountXlm must be greater than 0" }; + } + + if (input.initiatorWallet === input.beneficiaryWallet) { + return { success: false, error: "initiatorWallet and beneficiaryWallet must be different" }; + } + + // Validate Stellar addresses + try { + StellarSdk.Keypair.fromPublicKey(input.initiatorWallet); + StellarSdk.Keypair.fromPublicKey(input.beneficiaryWallet); + } catch { + return { success: false, error: "Invalid Stellar wallet address" }; + } + + // Verify group exists + const { data: group, error: groupError } = await supabase + .from("rooms") + .select("id") + .eq("id", input.groupId) + .maybeSingle(); + + if (groupError || !group) { + return { success: false, error: `Group '${input.groupId}' not found` }; + } + + // Build the memo group ID reference + const { memoValue } = buildGroupMemo(input.groupId); + + const { data, error } = await supabase + .from("escrows") + .insert({ + group_id: input.groupId, + initiator_wallet: input.initiatorWallet, + beneficiary_wallet: input.beneficiaryWallet, + amount_xlm: input.amountXlm, + asset_code: input.assetCode ?? "XLM", + asset_issuer: input.assetIssuer ?? null, + status: "pending" as EscrowStatus, + memo_group_id: memoValue, + expires_at: input.expiresAt ?? null, + }) + .select() + .single(); + + if (error) { + logBlockchainOperation( + "error", + "Failed to create escrow record", + { groupId: input.groupId, error: { type: "DBError", message: error.message } }, + correlationId + ); + return { success: false, error: "Failed to create escrow record" }; + } + + const escrow = data as EscrowRecord; + + await logEscrowEvent(supabase, escrow.id, "created", { + actorWallet: input.initiatorWallet, + metadata: { + groupId: input.groupId, + amountXlm: input.amountXlm, + beneficiaryWallet: input.beneficiaryWallet, + }, + }); + + logBlockchainOperation( + "info", + "Escrow created", + { escrowId: escrow.id, groupId: input.groupId, amountXlm: input.amountXlm }, + correlationId + ); + + return { success: true, escrow }; +} + +/** + * Marks an escrow as funded after the initiator has sent funds on-chain. + * The caller is responsible for submitting the funding transaction and + * providing the resulting txHash. + * + * Status transition: pending → funded + * + * @param input - Fund escrow parameters (includes the on-chain txHash) + * @param supabase - Supabase client + */ +export async function fundEscrow( + input: FundEscrowInput, + supabase: SupabaseClient +): Promise { + const correlationId = generateCorrelationId(); + + const escrow = await fetchEscrow(input.escrowId, supabase); + if (!escrow) { + return { success: false, error: `Escrow '${input.escrowId}' not found` }; + } + + const statusError = assertStatus(escrow, ["pending"]); + if (statusError) return statusError; + + if (escrow.initiator_wallet !== input.actorWallet) { + return { success: false, error: "Only the escrow initiator can fund it" }; + } + + // Check expiry + if (escrow.expires_at && new Date(escrow.expires_at) < new Date()) { + return { success: false, error: "Escrow has expired and cannot be funded" }; + } + + const { data, error } = await supabase + .from("escrows") + .update({ + status: "funded" as EscrowStatus, + fund_tx_hash: input.fundTxHash, + funded_at: new Date().toISOString(), + }) + .eq("id", input.escrowId) + .select() + .single(); + + if (error) { + return { success: false, error: "Failed to update escrow status" }; + } + + const updated = data as EscrowRecord; + + await logEscrowEvent(supabase, escrow.id, "funded", { + txHash: input.fundTxHash, + actorWallet: input.actorWallet, + metadata: { amountXlm: escrow.amount_xlm }, + }); + + logBlockchainOperation( + "info", + "Escrow funded", + { escrowId: escrow.id, txHash: input.fundTxHash }, + correlationId + ); + + return { success: true, escrow: updated, txHash: input.fundTxHash }; +} + +/** + * Releases escrowed funds to the beneficiary. + * Submits a Stellar payment transaction from the service wallet to the beneficiary. + * + * Status transition: funded → released + * + * @param input - Release parameters + * @param supabase - Supabase client + */ +export async function releaseEscrow( + input: ReleaseEscrowInput, + supabase: SupabaseClient +): Promise { + const correlationId = generateCorrelationId(); + + const escrow = await fetchEscrow(input.escrowId, supabase); + if (!escrow) { + return { success: false, error: `Escrow '${input.escrowId}' not found` }; + } + + const statusError = assertStatus(escrow, ["funded"]); + if (statusError) return statusError; + + // Only the initiator can release (or a resolver in dispute flow) + if (escrow.initiator_wallet !== input.actorWallet) { + return { success: false, error: "Only the escrow initiator can release funds" }; + } + + // Submit on-chain payment to beneficiary + const payment = await submitPayment({ + destinationPublicKey: escrow.beneficiary_wallet, + amountXlm: escrow.amount_xlm.toFixed(7), + groupId: escrow.group_id, + maxFee: input.maxFee, + }); + + if (!payment.success) { + await logEscrowEvent(supabase, escrow.id, "error", { + actorWallet: input.actorWallet, + metadata: { operation: "release", error: payment.error }, + }); + return { success: false, error: payment.error }; + } + + const config = loadStellarConfig(); + const explorerUrl = config && payment.txHash + ? getExplorerUrl(payment.txHash, config.network) + : undefined; + + const { data, error } = await supabase + .from("escrows") + .update({ + status: "released" as EscrowStatus, + release_tx_hash: payment.txHash, + released_at: new Date().toISOString(), + }) + .eq("id", input.escrowId) + .select() + .single(); + + if (error) { + logBlockchainOperation( + "warn", + "Escrow released on-chain but DB update failed", + { escrowId: escrow.id, txHash: payment.txHash, error: { type: "DBError", message: error.message } }, + correlationId + ); + } + + await logEscrowEvent(supabase, escrow.id, "released", { + txHash: payment.txHash, + actorWallet: input.actorWallet, + metadata: { amountXlm: escrow.amount_xlm, beneficiaryWallet: escrow.beneficiary_wallet }, + }); + + logBlockchainOperation( + "info", + "Escrow released", + { escrowId: escrow.id, txHash: payment.txHash, beneficiary: escrow.beneficiary_wallet }, + correlationId + ); + + return { + success: true, + escrow: (data ?? escrow) as EscrowRecord, + txHash: payment.txHash, + explorerUrl, + feeCharged: payment.feeCharged, + }; +} + +/** + * Refunds escrowed funds back to the initiator. + * Submits a Stellar payment transaction from the service wallet to the initiator. + * + * Status transition: funded → refunded + * + * @param input - Refund parameters + * @param supabase - Supabase client + */ +export async function refundEscrow( + input: RefundEscrowInput, + supabase: SupabaseClient +): Promise { + const correlationId = generateCorrelationId(); + + const escrow = await fetchEscrow(input.escrowId, supabase); + if (!escrow) { + return { success: false, error: `Escrow '${input.escrowId}' not found` }; + } + + const statusError = assertStatus(escrow, ["funded"]); + if (statusError) return statusError; + + // Beneficiary can request a refund, or the initiator can self-refund after expiry + const isInitiator = escrow.initiator_wallet === input.actorWallet; + const isBeneficiary = escrow.beneficiary_wallet === input.actorWallet; + const isExpired = escrow.expires_at ? new Date(escrow.expires_at) < new Date() : false; + + if (!isBeneficiary && !(isInitiator && isExpired)) { + return { + success: false, + error: isInitiator + ? "Initiator can only self-refund after the escrow has expired" + : "Only the beneficiary or the initiator (after expiry) can request a refund", + }; + } + + // Submit on-chain payment back to initiator + const payment = await submitPayment({ + destinationPublicKey: escrow.initiator_wallet, + amountXlm: escrow.amount_xlm.toFixed(7), + groupId: escrow.group_id, + maxFee: input.maxFee, + }); + + if (!payment.success) { + await logEscrowEvent(supabase, escrow.id, "error", { + actorWallet: input.actorWallet, + metadata: { operation: "refund", error: payment.error }, + }); + return { success: false, error: payment.error }; + } + + const config = loadStellarConfig(); + const explorerUrl = config && payment.txHash + ? getExplorerUrl(payment.txHash, config.network) + : undefined; + + const { data, error } = await supabase + .from("escrows") + .update({ + status: "refunded" as EscrowStatus, + refund_tx_hash: payment.txHash, + refunded_at: new Date().toISOString(), + }) + .eq("id", input.escrowId) + .select() + .single(); + + if (error) { + logBlockchainOperation( + "warn", + "Escrow refunded on-chain but DB update failed", + { escrowId: escrow.id, txHash: payment.txHash, error: { type: "DBError", message: error.message } }, + correlationId + ); + } + + await logEscrowEvent(supabase, escrow.id, "refunded", { + txHash: payment.txHash, + actorWallet: input.actorWallet, + metadata: { amountXlm: escrow.amount_xlm, initiatorWallet: escrow.initiator_wallet }, + }); + + logBlockchainOperation( + "info", + "Escrow refunded", + { escrowId: escrow.id, txHash: payment.txHash, initiator: escrow.initiator_wallet }, + correlationId + ); + + return { + success: true, + escrow: (data ?? escrow) as EscrowRecord, + txHash: payment.txHash, + explorerUrl, + feeCharged: payment.feeCharged, + }; +} + +/** + * Raises a dispute on a funded escrow. + * No on-chain transaction — marks the escrow for manual/DAO resolution. + * + * Status transition: funded → disputed + * + * @param input - Dispute parameters + * @param supabase - Supabase client + */ +export async function disputeEscrow( + input: DisputeEscrowInput, + supabase: SupabaseClient +): Promise { + const correlationId = generateCorrelationId(); + + const escrow = await fetchEscrow(input.escrowId, supabase); + if (!escrow) { + return { success: false, error: `Escrow '${input.escrowId}' not found` }; + } + + const statusError = assertStatus(escrow, ["funded"]); + if (statusError) return statusError; + + // Either party can raise a dispute + const isParticipant = + escrow.initiator_wallet === input.actorWallet || + escrow.beneficiary_wallet === input.actorWallet; + + if (!isParticipant) { + return { success: false, error: "Only escrow participants can raise a dispute" }; + } + + if (!input.reason || input.reason.trim().length === 0) { + return { success: false, error: "A dispute reason is required" }; + } + + const { data, error } = await supabase + .from("escrows") + .update({ + status: "disputed" as EscrowStatus, + dispute_reason: input.reason.trim(), + disputed_at: new Date().toISOString(), + }) + .eq("id", input.escrowId) + .select() + .single(); + + if (error) { + return { success: false, error: "Failed to update escrow status" }; + } + + await logEscrowEvent(supabase, escrow.id, "disputed", { + actorWallet: input.actorWallet, + metadata: { reason: input.reason }, + }); + + logBlockchainOperation( + "info", + "Escrow disputed", + { escrowId: escrow.id, actorWallet: input.actorWallet }, + correlationId + ); + + return { success: true, escrow: data as EscrowRecord }; +} + +/** + * Resolves a disputed escrow by releasing funds to either party. + * Submits the appropriate on-chain payment based on the resolver's decision. + * + * Status transition: disputed → resolved + * + * Extensibility note: the resolverUserId can be replaced with a DAO vote + * result or multi-sig approval in future iterations. + * + * @param input - Resolution parameters + * @param supabase - Supabase client + */ +export async function resolveDispute( + input: ResolveDisputeInput, + supabase: SupabaseClient +): Promise { + const correlationId = generateCorrelationId(); + + const escrow = await fetchEscrow(input.escrowId, supabase); + if (!escrow) { + return { success: false, error: `Escrow '${input.escrowId}' not found` }; + } + + const statusError = assertStatus(escrow, ["disputed"]); + if (statusError) return statusError; + + // Determine destination based on resolution decision + const destinationWallet = input.releaseToInitiator + ? escrow.initiator_wallet + : escrow.beneficiary_wallet; + + const payment = await submitPayment({ + destinationPublicKey: destinationWallet, + amountXlm: escrow.amount_xlm.toFixed(7), + groupId: escrow.group_id, + maxFee: input.maxFee, + }); + + if (!payment.success) { + await logEscrowEvent(supabase, escrow.id, "error", { + metadata: { operation: "resolve", error: payment.error }, + }); + return { success: false, error: payment.error }; + } + + const config = loadStellarConfig(); + const explorerUrl = config && payment.txHash + ? getExplorerUrl(payment.txHash, config.network) + : undefined; + + // Determine final status based on where funds went + const finalStatus: EscrowStatus = input.releaseToInitiator ? "refunded" : "released"; + const txField = input.releaseToInitiator ? "refund_tx_hash" : "release_tx_hash"; + const timestampField = input.releaseToInitiator ? "refunded_at" : "released_at"; + + const { data, error } = await supabase + .from("escrows") + .update({ + status: "resolved" as EscrowStatus, + [txField]: payment.txHash, + [timestampField]: new Date().toISOString(), + resolved_by: input.resolverUserId, + resolved_at: new Date().toISOString(), + }) + .eq("id", input.escrowId) + .select() + .single(); + + if (error) { + logBlockchainOperation( + "warn", + "Dispute resolved on-chain but DB update failed", + { escrowId: escrow.id, txHash: payment.txHash, error: { type: "DBError", message: error.message } }, + correlationId + ); + } + + await logEscrowEvent(supabase, escrow.id, "resolved", { + txHash: payment.txHash, + metadata: { + resolverUserId: input.resolverUserId, + releaseToInitiator: input.releaseToInitiator, + destinationWallet, + }, + }); + + logBlockchainOperation( + "info", + "Dispute resolved", + { + escrowId: escrow.id, + txHash: payment.txHash, + destinationWallet, + finalStatus, + }, + correlationId + ); + + return { + success: true, + escrow: (data ?? escrow) as EscrowRecord, + txHash: payment.txHash, + explorerUrl, + feeCharged: payment.feeCharged, + }; +} + +/** + * Retrieves a single escrow record by ID. + * + * @param escrowId - UUID of the escrow + * @param supabase - Supabase client + */ +export async function getEscrow( + escrowId: string, + supabase: SupabaseClient +): Promise { + const escrow = await fetchEscrow(escrowId, supabase); + if (!escrow) { + return { success: false, error: `Escrow '${escrowId}' not found` }; + } + return { success: true, escrow }; +} + +/** + * Lists all escrows for a given group, ordered by creation date (newest first). + * + * @param groupId - The room/group ID + * @param supabase - Supabase client + */ +export async function listEscrowsByGroup( + groupId: string, + supabase: SupabaseClient +): Promise { + const { data, error } = await supabase + .from("escrows") + .select("*") + .eq("group_id", groupId) + .order("created_at", { ascending: false }); + + if (error) { + logBlockchainOperation("error", "Failed to list escrows by group", { + groupId, + error: { type: "DBError", message: error.message }, + }); + return []; + } + + return (data ?? []) as EscrowRecord[]; +} + +/** + * Lists all escrows where the given wallet is either initiator or beneficiary. + * + * @param walletAddress - Stellar public key + * @param supabase - Supabase client + */ +export async function listEscrowsByWallet( + walletAddress: string, + supabase: SupabaseClient +): Promise { + const { data, error } = await supabase + .from("escrows") + .select("*") + .or(`initiator_wallet.eq.${walletAddress},beneficiary_wallet.eq.${walletAddress}`) + .order("created_at", { ascending: false }); + + if (error) { + logBlockchainOperation("error", "Failed to list escrows by wallet", { + error: { type: "DBError", message: error.message }, + }); + return []; + } + + return (data ?? []) as EscrowRecord[]; +} + +/** + * Retrieves the full event history for an escrow. + * + * @param escrowId - UUID of the escrow + * @param supabase - Supabase client + */ +export async function getEscrowEvents( + escrowId: string, + supabase: SupabaseClient +) { + const { data, error } = await supabase + .from("escrow_events") + .select("*") + .eq("escrow_id", escrowId) + .order("created_at", { ascending: true }); + + if (error) { + logBlockchainOperation("error", "Failed to fetch escrow events", { + escrowId, + error: { type: "DBError", message: error.message }, + }); + return []; + } + + return data ?? []; +} diff --git a/lib/blockchain/memo-service.ts b/lib/blockchain/memo-service.ts new file mode 100644 index 0000000..1423b44 --- /dev/null +++ b/lib/blockchain/memo-service.ts @@ -0,0 +1,310 @@ +/** + * Memo Service — Group ID ↔ Transaction Mapping + * + * Responsibilities: + * 1. Submit a Stellar transaction whose memo encodes the group ID + * 2. Persist the groupId ↔ txHash mapping in the DB + * 3. Validate memo integrity on retrieval + * 4. Provide lookup helpers for controllers + */ + +import * as StellarSdk from "@stellar/stellar-sdk"; +import { SupabaseClient } from "@supabase/supabase-js"; +import { loadStellarConfig, isConfigured, getExplorerUrl } from "./stellar-config"; +import { logBlockchainOperation, generateCorrelationId } from "./logger"; +import { buildGroupMemo, validateGroupMemo, MemoStrategy } from "./memo"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface MemoSubmissionResult { + success: boolean; + txHash?: string; + memoValue?: string; + memoType?: MemoStrategy; + explorerUrl?: string; + feeCharged?: string; + error?: string; +} + +export interface MemoRecord { + id: string; + group_id: string; + tx_hash: string; + memo_value: string; + memo_type: MemoStrategy; + submitted_at: string; + verified_at: string | null; + is_valid: boolean; +} + +export interface MemoValidationResult { + groupId: string; + txHash: string; + memoValue: string; + memoType: MemoStrategy; + isValid: boolean; + verifiedAt: string | null; +} + +// ── Core service functions ──────────────────────────────────────────────────── + +/** + * Submits a Stellar transaction with the group ID embedded in the memo field, + * then persists the groupId ↔ txHash mapping in the database. + * + * @param groupId - The room/group ID to embed + * @param supabase - Supabase client (service-role recommended for DB writes) + * @param userId - Optional user ID for audit trail + * @param maxFee - Optional custom fee in stroops + */ +export async function submitGroupMemoTransaction( + groupId: string, + supabase: SupabaseClient, + userId?: string, + maxFee?: string | number +): Promise { + const correlationId = generateCorrelationId(); + + if (!isConfigured()) { + logBlockchainOperation( + "warn", + "Skipping memo transaction — Stellar config missing", + { groupId }, + correlationId + ); + return { success: false, error: "Stellar configuration not available" }; + } + + const config = loadStellarConfig(); + if (!config) { + return { success: false, error: "Failed to load Stellar configuration" }; + } + + // Build the memo + const { memo, memoValue, memoType } = buildGroupMemo(groupId); + + logBlockchainOperation( + "info", + "Submitting group memo transaction", + { groupId, memoValue, memoType, network: config.network }, + correlationId + ); + + try { + const server = new StellarSdk.Horizon.Server(config.horizonUrl); + const sourceKeypair = StellarSdk.Keypair.fromSecret(config.sourceSecret); + const sourcePublicKey = sourceKeypair.publicKey(); + + const account = await Promise.race([ + server.loadAccount(sourcePublicKey), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Timeout loading account")), + config.transactionTimeout + ) + ), + ]); + + const feeToUse = maxFee ? maxFee.toString() : StellarSdk.BASE_FEE; + + const transaction = new StellarSdk.TransactionBuilder(account, { + fee: feeToUse, + networkPassphrase: + config.network === "testnet" + ? StellarSdk.Networks.TESTNET + : StellarSdk.Networks.PUBLIC, + }) + .addOperation( + StellarSdk.Operation.payment({ + destination: sourcePublicKey, // self-payment — minimal cost + asset: StellarSdk.Asset.native(), + amount: "0.0000001", + }) + ) + .addMemo(memo) + .setTimeout(30) + .build(); + + transaction.sign(sourceKeypair); + + const result = await Promise.race([ + server.submitTransaction(transaction), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Transaction submission timeout")), + config.transactionTimeout + ) + ), + ]); + + const txHash = result.hash; + const feeCharged = (result as any).fee_charged?.toString() ?? feeToUse.toString(); + const explorerUrl = getExplorerUrl(txHash, config.network); + + logBlockchainOperation( + "info", + "Group memo transaction submitted", + { groupId, txHash, memoValue, memoType, feeCharged }, + correlationId + ); + + // Persist the mapping in the DB + const { error: dbError } = await supabase.from("group_tx_memo_map").insert({ + group_id: groupId, + tx_hash: txHash, + memo_value: memoValue, + memo_type: memoType, + is_valid: true, + verified_at: new Date().toISOString(), + created_by: userId ?? null, + }); + + if (dbError) { + // Non-fatal: log but still return success since the tx is on-chain + logBlockchainOperation( + "warn", + "Failed to persist memo mapping in DB", + { groupId, txHash, error: { type: "DBError", message: dbError.message } }, + correlationId + ); + } + + return { + success: true, + txHash, + memoValue, + memoType, + explorerUrl, + feeCharged, + }; + } catch (error: any) { + logBlockchainOperation( + "error", + "Group memo transaction failed", + { + groupId, + error: { + type: error.name ?? "UnknownError", + message: error.message ?? "Unknown error", + }, + }, + correlationId + ); + + return { success: false, error: error.message ?? "Transaction failed" }; + } +} + +/** + * Validates the memo integrity of a stored mapping record. + * Re-checks that the stored memo_value is consistent with the group_id. + * + * @param record - A row from group_tx_memo_map + * @returns MemoValidationResult + */ +export function validateMemoRecord(record: MemoRecord): MemoValidationResult { + const isValid = validateGroupMemo( + record.memo_value, + record.memo_type as MemoStrategy, + record.group_id + ); + + return { + groupId: record.group_id, + txHash: record.tx_hash, + memoValue: record.memo_value, + memoType: record.memo_type as MemoStrategy, + isValid, + verifiedAt: record.verified_at, + }; +} + +/** + * Looks up all memo mappings for a given group ID. + * + * @param groupId - The room/group ID + * @param supabase - Supabase client + * @returns Array of MemoRecord rows, newest first + */ +export async function getMemosByGroupId( + groupId: string, + supabase: SupabaseClient +): Promise { + const { data, error } = await supabase + .from("group_tx_memo_map") + .select("*") + .eq("group_id", groupId) + .order("submitted_at", { ascending: false }); + + if (error) { + logBlockchainOperation("error", "Failed to fetch memo mappings", { + groupId, + error: { type: "DBError", message: error.message }, + }); + return []; + } + + return (data ?? []) as MemoRecord[]; +} + +/** + * Looks up the memo mapping for a specific transaction hash. + * + * @param txHash - Stellar transaction hash + * @param supabase - Supabase client + * @returns MemoRecord or null + */ +export async function getMemoByTxHash( + txHash: string, + supabase: SupabaseClient +): Promise { + const { data, error } = await supabase + .from("group_tx_memo_map") + .select("*") + .eq("tx_hash", txHash) + .maybeSingle(); + + if (error) { + logBlockchainOperation("error", "Failed to fetch memo by tx hash", { + transactionHash: txHash, + error: { type: "DBError", message: error.message }, + }); + return null; + } + + return data as MemoRecord | null; +} + +/** + * Validates memo integrity for all records belonging to a group and + * updates the is_valid flag in the DB accordingly. + * + * @param groupId - The room/group ID + * @param supabase - Supabase client (service-role for updates) + * @returns Array of validation results + */ +export async function revalidateGroupMemos( + groupId: string, + supabase: SupabaseClient +): Promise { + const records = await getMemosByGroupId(groupId, supabase); + const results: MemoValidationResult[] = []; + + for (const record of records) { + const validation = validateMemoRecord(record); + results.push(validation); + + // Update DB if validity changed + if (validation.isValid !== record.is_valid) { + await supabase + .from("group_tx_memo_map") + .update({ + is_valid: validation.isValid, + verified_at: new Date().toISOString(), + }) + .eq("id", record.id); + } + } + + return results; +} diff --git a/lib/blockchain/memo-validation.ts b/lib/blockchain/memo-validation.ts new file mode 100644 index 0000000..037a07a --- /dev/null +++ b/lib/blockchain/memo-validation.ts @@ -0,0 +1,230 @@ +/** + * Memo Validation Middleware + * + * Provides request-level validation for memo-related payloads before + * they reach controllers. Checks: + * - Presence of required fields (groupId, txHash) + * - Group ID existence in the DB + * - Memo value integrity (format + DB record consistency) + * - Memo type validity + */ + +import { NextResponse } from "next/server"; +import { SupabaseClient } from "@supabase/supabase-js"; +import { validateGroupMemo, getMemoStrategy, STELLAR_MEMO_TEXT_MAX_BYTES } from "./memo"; +import { getMemoByTxHash, getMemosByGroupId } from "./memo-service"; + +export type MemoValidationError = + | { valid: false; response: NextResponse } + | { valid: true }; + +/** + * Validates that a groupId is non-empty and exists in the rooms table. + */ +export async function validateGroupIdExists( + groupId: string, + supabase: SupabaseClient +): Promise { + if (!groupId || typeof groupId !== "string" || groupId.trim() === "") { + return { + valid: false, + response: NextResponse.json( + { error: "groupId is required and must be a non-empty string" }, + { status: 400 } + ), + }; + } + + const { data, error } = await supabase + .from("rooms") + .select("id") + .eq("id", groupId) + .maybeSingle(); + + if (error) { + return { + valid: false, + response: NextResponse.json( + { error: "Failed to validate group ID" }, + { status: 500 } + ), + }; + } + + if (!data) { + return { + valid: false, + response: NextResponse.json( + { error: `Group '${groupId}' does not exist` }, + { status: 404 } + ), + }; + } + + return { valid: true }; +} + +/** + * Validates a memo value against a known group ID. + * Checks format correctness and byte-length constraints. + */ +export function validateMemoValue( + memoValue: string, + memoType: string, + groupId: string +): MemoValidationError { + if (!memoValue || typeof memoValue !== "string") { + return { + valid: false, + response: NextResponse.json( + { error: "memoValue is required" }, + { status: 400 } + ), + }; + } + + if (!["text", "hash", "id", "return"].includes(memoType)) { + return { + valid: false, + response: NextResponse.json( + { error: "memoType must be one of: text, hash, id, return" }, + { status: 400 } + ), + }; + } + + // For text memos, enforce byte-length limit + if (memoType === "text") { + const byteLen = Buffer.byteLength(memoValue, "utf8"); + if (byteLen > STELLAR_MEMO_TEXT_MAX_BYTES) { + return { + valid: false, + response: NextResponse.json( + { + error: `Memo text exceeds ${STELLAR_MEMO_TEXT_MAX_BYTES}-byte limit (got ${byteLen} bytes)`, + }, + { status: 400 } + ), + }; + } + } + + // Validate memo integrity against the group ID + if (memoType === "text" || memoType === "hash") { + const isValid = validateGroupMemo( + memoValue, + memoType as "text" | "hash", + groupId + ); + if (!isValid) { + return { + valid: false, + response: NextResponse.json( + { + error: "Memo value does not match the expected format for this group ID", + hint: + memoType === "text" + ? `Expected: grp:${groupId}` + : "Expected: SHA-256 hex of the group ID", + }, + { status: 422 } + ), + }; + } + } + + return { valid: true }; +} + +/** + * Validates that a txHash has a corresponding memo record in the DB + * and that the record's memo is consistent with the given groupId. + */ +export async function validateTxMemoRecord( + txHash: string, + groupId: string, + supabase: SupabaseClient +): Promise { + if (!txHash || typeof txHash !== "string" || txHash.trim() === "") { + return { + valid: false, + response: NextResponse.json( + { error: "txHash is required" }, + { status: 400 } + ), + }; + } + + const record = await getMemoByTxHash(txHash, supabase); + + if (!record) { + return { + valid: false, + response: NextResponse.json( + { error: `No memo record found for transaction '${txHash}'` }, + { status: 404 } + ), + }; + } + + if (record.group_id !== groupId) { + return { + valid: false, + response: NextResponse.json( + { + error: "Transaction memo is linked to a different group ID", + expected: groupId, + found: record.group_id, + }, + { status: 409 } + ), + }; + } + + if (!record.is_valid) { + return { + valid: false, + response: NextResponse.json( + { error: "Memo record exists but failed integrity validation" }, + { status: 422 } + ), + }; + } + + return { valid: true }; +} + +/** + * Checks whether a group already has at least one valid memo transaction. + * Useful to prevent duplicate submissions. + */ +export async function groupHasValidMemo( + groupId: string, + supabase: SupabaseClient +): Promise { + const records = await getMemosByGroupId(groupId, supabase); + return records.some((r) => r.is_valid); +} + +/** + * Determines the recommended memo strategy for a group ID and returns + * a human-readable explanation. Useful for client-side guidance. + */ +export function getMemoStrategyInfo(groupId: string): { + strategy: "text" | "hash"; + explanation: string; + byteLength: number; +} { + const candidate = `grp:${groupId}`; + const byteLength = Buffer.byteLength(candidate, "utf8"); + const strategy = getMemoStrategy(groupId); + + return { + strategy, + byteLength, + explanation: + strategy === "text" + ? `Group ID fits in 28 bytes as text memo: "${candidate}"` + : `Group ID (${byteLength} bytes) exceeds 28-byte limit; SHA-256 hash memo will be used`, + }; +} diff --git a/lib/blockchain/memo.ts b/lib/blockchain/memo.ts new file mode 100644 index 0000000..5dded8b --- /dev/null +++ b/lib/blockchain/memo.ts @@ -0,0 +1,127 @@ +/** + * Stellar Memo — Group ID Utilities + * + * Stellar's native memo field supports several types: + * - MEMO_TEXT : UTF-8 string, max 28 bytes + * - MEMO_HASH : 32-byte hash (opaque binary) + * - MEMO_ID : uint64 integer + * - MEMO_RETURN: 32-byte hash (return payment) + * + * We use MEMO_TEXT for human-readable group references and MEMO_HASH + * when the group ID exceeds 28 bytes (we hash it to a fixed 32 bytes). + * + * Memo format for text: "grp:" (prefix makes intent clear) + * Memo format for hash: SHA-256(groupId) as raw 32-byte Buffer + */ + +import { createHash } from "crypto"; +import * as StellarSdk from "@stellar/stellar-sdk"; + +/** Maximum byte length for a Stellar MEMO_TEXT value */ +export const STELLAR_MEMO_TEXT_MAX_BYTES = 28; + +/** Prefix used to namespace group memos */ +const GROUP_MEMO_PREFIX = "grp:"; + +export type MemoStrategy = "text" | "hash"; + +export interface GroupMemoResult { + memo: StellarSdk.Memo; + memoValue: string; // human-readable representation stored in DB + memoType: MemoStrategy; +} + +/** + * Builds a Stellar Memo that encodes the given groupId. + * + * Strategy selection: + * - If "grp:" fits within 28 bytes → MEMO_TEXT + * - Otherwise → MEMO_HASH (SHA-256 of the groupId, 32 bytes) + * + * @param groupId - The group/room ID to embed + * @returns GroupMemoResult with the Memo object and metadata for DB storage + */ +export function buildGroupMemo(groupId: string): GroupMemoResult { + const candidate = `${GROUP_MEMO_PREFIX}${groupId}`; + const byteLength = Buffer.byteLength(candidate, "utf8"); + + if (byteLength <= STELLAR_MEMO_TEXT_MAX_BYTES) { + return { + memo: StellarSdk.Memo.text(candidate), + memoValue: candidate, + memoType: "text", + }; + } + + // Fall back to MEMO_HASH: SHA-256 of the groupId (32 bytes) + const hashBuffer = createHash("sha256").update(groupId, "utf8").digest(); + return { + memo: StellarSdk.Memo.hash(hashBuffer.toString("hex")), + memoValue: hashBuffer.toString("hex"), + memoType: "hash", + }; +} + +/** + * Extracts the group ID from a raw memo value stored in the DB. + * + * For text memos: strips the "grp:" prefix. + * For hash memos: returns the hex string as-is (cannot reverse a hash). + * + * @param memoValue - The stored memo value + * @param memoType - The memo type ("text" | "hash") + * @returns The group ID string, or null if the memo doesn't match our format + */ +export function extractGroupIdFromMemo( + memoValue: string, + memoType: MemoStrategy +): string | null { + if (memoType === "text") { + if (memoValue.startsWith(GROUP_MEMO_PREFIX)) { + return memoValue.slice(GROUP_MEMO_PREFIX.length); + } + return null; + } + + // For hash memos we cannot reverse, so return the hash itself + return memoValue; +} + +/** + * Validates that a memo value is consistent with the expected group ID. + * + * For text memos: checks the "grp:" format. + * For hash memos: recomputes SHA-256(groupId) and compares. + * + * @param memoValue - The memo value to validate + * @param memoType - The memo type + * @param groupId - The expected group ID + * @returns true if the memo is valid for the given group + */ +export function validateGroupMemo( + memoValue: string, + memoType: MemoStrategy, + groupId: string +): boolean { + if (memoType === "text") { + return memoValue === `${GROUP_MEMO_PREFIX}${groupId}`; + } + + // Hash memo: recompute and compare + const expected = createHash("sha256").update(groupId, "utf8").digest("hex"); + return memoValue === expected; +} + +/** + * Determines the appropriate memo strategy for a given group ID without + * building the full Memo object. Useful for pre-flight checks. + * + * @param groupId - The group ID to evaluate + * @returns "text" if it fits in 28 bytes, "hash" otherwise + */ +export function getMemoStrategy(groupId: string): MemoStrategy { + const candidate = `${GROUP_MEMO_PREFIX}${groupId}`; + return Buffer.byteLength(candidate, "utf8") <= STELLAR_MEMO_TEXT_MAX_BYTES + ? "text" + : "hash"; +} diff --git a/scripts/011_group_tx_memo_map.sql b/scripts/011_group_tx_memo_map.sql new file mode 100644 index 0000000..89caa80 --- /dev/null +++ b/scripts/011_group_tx_memo_map.sql @@ -0,0 +1,46 @@ +-- Migration 011: Group ↔ Transaction memo mapping table +-- Stores the mapping between group IDs and Stellar transaction IDs +-- for quick lookup and memo integrity validation. + +create table if not exists public.group_tx_memo_map ( + id uuid primary key default gen_random_uuid(), + group_id text not null references public.rooms(id) on delete cascade, + tx_hash text not null, + memo_value text not null, -- the exact memo text stored on-chain (≤28 bytes) + memo_type text not null default 'text' check (memo_type in ('text', 'hash', 'id', 'return')), + submitted_at timestamp with time zone default timezone('utc', now()) not null, + verified_at timestamp with time zone, + is_valid boolean default false not null, + created_by uuid references auth.users(id) on delete set null, + + -- A group can have multiple transactions (e.g. updates), but each tx_hash is unique + unique (tx_hash) +); + +create index if not exists group_tx_memo_map_group_id_idx on public.group_tx_memo_map(group_id); +create index if not exists group_tx_memo_map_tx_hash_idx on public.group_tx_memo_map(tx_hash); + +alter table public.group_tx_memo_map enable row level security; + +-- Authenticated users can read memo mappings for rooms they are members of +create policy "Members can view memo mappings" + on public.group_tx_memo_map for select + using ( + exists ( + select 1 from public.room_members rm + where rm.room_id = group_tx_memo_map.group_id + and rm.user_id = auth.uid() + and rm.removed_at is null + ) + or exists ( + select 1 from public.rooms r + where r.id = group_tx_memo_map.group_id + and r.is_private = false + ) + ); + +-- Only service role can insert / update (done via API with service key) +create policy "Service role can manage memo mappings" + on public.group_tx_memo_map for all + using (auth.role() = 'service_role') + with check (auth.role() = 'service_role'); diff --git a/scripts/012_escrow_tables.sql b/scripts/012_escrow_tables.sql new file mode 100644 index 0000000..54bd984 --- /dev/null +++ b/scripts/012_escrow_tables.sql @@ -0,0 +1,85 @@ +-- Migration 012: Escrow lifecycle tables +-- Supports create / fund / release / refund / dispute operations. + +-- ── Escrow accounts ────────────────────────────────────────────────────────── +create table if not exists public.escrows ( + id uuid primary key default gen_random_uuid(), + group_id text not null references public.rooms(id) on delete restrict, + initiator_wallet text not null, -- Stellar public key of the creator + beneficiary_wallet text not null, -- Stellar public key of the recipient + amount_xlm numeric(20, 7) not null check (amount_xlm > 0), + asset_code text not null default 'XLM', + asset_issuer text, -- null for native XLM + status text not null default 'pending' + check (status in ('pending','funded','released','refunded','disputed','resolved')), + memo_group_id text, -- group ID embedded in the Stellar memo + fund_tx_hash text, -- Stellar tx that funded the escrow + release_tx_hash text, -- Stellar tx that released funds + refund_tx_hash text, -- Stellar tx that refunded funds + dispute_reason text, + resolved_by uuid references auth.users(id) on delete set null, + created_at timestamp with time zone default timezone('utc', now()) not null, + funded_at timestamp with time zone, + released_at timestamp with time zone, + refunded_at timestamp with time zone, + disputed_at timestamp with time zone, + resolved_at timestamp with time zone, + expires_at timestamp with time zone -- optional expiry for auto-refund +); + +create index if not exists escrows_group_id_idx on public.escrows(group_id); +create index if not exists escrows_initiator_wallet_idx on public.escrows(initiator_wallet); +create index if not exists escrows_status_idx on public.escrows(status); + +alter table public.escrows enable row level security; + +-- Participants (initiator or beneficiary profile) can view their escrows +create policy "Participants can view escrows" + on public.escrows for select + using ( + exists ( + select 1 from public.profiles p + where p.id = auth.uid() + and (p.wallet_address = escrows.initiator_wallet + or p.wallet_address = escrows.beneficiary_wallet) + ) + ); + +-- Service role manages all escrow operations +create policy "Service role manages escrows" + on public.escrows for all + using (auth.role() = 'service_role') + with check (auth.role() = 'service_role'); + +-- ── Escrow event log ───────────────────────────────────────────────────────── +create table if not exists public.escrow_events ( + id uuid primary key default gen_random_uuid(), + escrow_id uuid not null references public.escrows(id) on delete cascade, + event_type text not null + check (event_type in ('created','funded','released','refunded','disputed','resolved','error')), + tx_hash text, + actor_wallet text, + metadata jsonb default '{}'::jsonb, + created_at timestamp with time zone default timezone('utc', now()) not null +); + +create index if not exists escrow_events_escrow_id_idx on public.escrow_events(escrow_id); + +alter table public.escrow_events enable row level security; + +create policy "Participants can view escrow events" + on public.escrow_events for select + using ( + exists ( + select 1 from public.escrows e + join public.profiles p on p.id = auth.uid() + where e.id = escrow_events.escrow_id + and (p.wallet_address = e.initiator_wallet + or p.wallet_address = e.beneficiary_wallet) + ) + ); + +create policy "Service role manages escrow events" + on public.escrow_events for all + using (auth.role() = 'service_role') + with check (auth.role() = 'service_role'); diff --git a/types/blockchain.ts b/types/blockchain.ts index 3034a60..2851095 100644 --- a/types/blockchain.ts +++ b/types/blockchain.ts @@ -50,5 +50,10 @@ export interface GroupCreationResponse { transactionHash?: string; feeCharged?: string; explorerUrl?: string; + memo?: { + txHash: string; + memoValue: string | null; + memoType: string | null; + }; }; } diff --git a/types/escrow.ts b/types/escrow.ts new file mode 100644 index 0000000..7c3e07a --- /dev/null +++ b/types/escrow.ts @@ -0,0 +1,115 @@ +/** + * Escrow type definitions + * + * These types mirror the DB schema in scripts/012_escrow_tables.sql + * and define the public API surface of the escrow service layer. + */ + +// ── Status ──────────────────────────────────────────────────────────────────── + +export type EscrowStatus = + | "pending" // created, not yet funded + | "funded" // funds locked on-chain + | "released" // funds sent to beneficiary + | "refunded" // funds returned to initiator + | "disputed" // dispute raised, awaiting resolution + | "resolved"; // dispute resolved + +export type EscrowEventType = + | "created" + | "funded" + | "released" + | "refunded" + | "disputed" + | "resolved" + | "error"; + +// ── DB row shapes ───────────────────────────────────────────────────────────── + +export interface EscrowRecord { + id: string; + group_id: string; + initiator_wallet: string; + beneficiary_wallet: string; + amount_xlm: number; + asset_code: string; + asset_issuer: string | null; + status: EscrowStatus; + memo_group_id: string | null; + fund_tx_hash: string | null; + release_tx_hash: string | null; + refund_tx_hash: string | null; + dispute_reason: string | null; + resolved_by: string | null; + created_at: string; + funded_at: string | null; + released_at: string | null; + refunded_at: string | null; + disputed_at: string | null; + resolved_at: string | null; + expires_at: string | null; +} + +export interface EscrowEventRecord { + id: string; + escrow_id: string; + event_type: EscrowEventType; + tx_hash: string | null; + actor_wallet: string | null; + metadata: Record; + created_at: string; +} + +// ── Service input/output types ──────────────────────────────────────────────── + +export interface CreateEscrowInput { + groupId: string; + initiatorWallet: string; + beneficiaryWallet: string; + amountXlm: number; + assetCode?: string; // defaults to "XLM" + assetIssuer?: string; // null for native XLM + expiresAt?: string; // ISO-8601 datetime +} + +export interface FundEscrowInput { + escrowId: string; + fundTxHash: string; // Stellar tx hash that transferred funds + actorWallet: string; +} + +export interface ReleaseEscrowInput { + escrowId: string; + actorWallet: string; // must be initiator or authorised party + maxFee?: string | number; +} + +export interface RefundEscrowInput { + escrowId: string; + actorWallet: string; + maxFee?: string | number; +} + +export interface DisputeEscrowInput { + escrowId: string; + actorWallet: string; + reason: string; +} + +export interface ResolveDisputeInput { + escrowId: string; + resolverUserId: string; // Supabase user ID of the resolver + releaseToInitiator: boolean; // true → refund, false → release to beneficiary + maxFee?: string | number; +} + +// ── Service result types ────────────────────────────────────────────────────── + +export interface EscrowResult { + success: boolean; + escrow?: EscrowRecord; + txHash?: string; + explorerUrl?: string; + feeCharged?: string; + error?: string; +} From 9c3aca7b5e8ed43cdaab952c4677fc95350ac95d Mon Sep 17 00:00:00 2001 From: pugsley76 Date: Sun, 26 Apr 2026 08:39:55 -0700 Subject: [PATCH 2/4] docs: update README and add user guide - Rewrite README with accurate current status table, tested Getting Started steps, correct migration order, env vars, CI/Vercel/License badges, and live demo link - Add docs/user-guide.md: step-by-step guide for non-technical users covering Freighter install, testnet funding, wallet auth, creating/joining groups, messaging, and troubleshooting - Update hero: replace placeholder Learn More with User Guide link - Update footer: replace all placeholder hash links with real links to features, docs, GitHub, and contributing --- README.md | 367 +++++++++++++++++++++++++----------------- components/footer.tsx | 89 +++++----- components/hero.tsx | 18 ++- docs/user-guide.md | 265 ++++++++++++++++++++++++++++++ 4 files changed, 544 insertions(+), 195 deletions(-) create mode 100644 docs/user-guide.md diff --git a/README.md b/README.md index 6b9480d..f28b1a0 100644 --- a/README.md +++ b/README.md @@ -1,251 +1,318 @@ # AnonChat 🌌 -**AnonChat** is a **Stellar-based anonymous communication platform** that allows users to create groups and chat freely with strangers — **without revealing identity**. Access is powered by **Web3 wallet authentication**, ensuring privacy, decentralization, and user sovereignty. +**AnonChat** is a **Stellar-based anonymous communication platform** that lets users create and join chat groups without revealing their identity. Authentication is powered by Web3 wallet signatures — no email, no phone number, no personal data. > Speak freely. Stay anonymous. Powered by Stellar. ---- - -## 🚀 What is AnonChat? - -AnonChat is a decentralized, privacy-first chat application where: - -* Users **connect using a Web3 wallet** -* No personal data, email, or phone number is required -* Users can **create or join anonymous groups** -* Messages are **end-to-end encrypted** -* Identity is never exposed — not even to us - -The platform leverages **Stellar blockchain primitives** to ensure transparency, decentralization, and trustless authentication. +[![CI](https://github.com/your-username/AnonChat/actions/workflows/ci.yml/badge.svg)](https://github.com/your-username/AnonChat/actions/workflows/ci.yml) +[![Vercel](https://img.shields.io/badge/deployed-Vercel-black?logo=vercel)](https://anonchat-one.vercel.app) +[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](LICENSE) +[![Node.js](https://img.shields.io/badge/node-%3E%3D20.9.0-brightgreen)](https://nodejs.org) --- -## 🌟 Why Stellar? - -AnonChat is **built on Stellar** because it offers: +## 🌐 Live Demo -* ⚡ **Fast & low-cost transactions** -* 🌍 **Global, borderless infrastructure** -* 🔐 **Secure public-key cryptography** -* 🧩 Perfect fit for **wallet-based authentication** +🔗 **[https://anonchat-one.vercel.app](https://anonchat-one.vercel.app)** -Stellar enables AnonChat to remain lightweight, scalable, and censorship-resistant. +--- +## 📋 Current Status + +### ✅ Implemented and working + +| Feature | Notes | +|---|---| +| Stellar wallet authentication | Ed25519 signature verification, nonce-based replay protection | +| Anonymous chat rooms | Create public groups, list and search rooms | +| Real-time messaging | Supabase Realtime, message status (sending → sent → delivered → read) | +| Blockchain metadata anchoring | Group metadata SHA-256 hash submitted to Stellar via self-payment memo | +| On-chain group verification | Verify a room's metadata hash against its Stellar transaction | +| Member management | View members, vote to remove members (majority threshold) | +| Escrow system | Full lifecycle: create → fund → release / refund / dispute → resolve | +| Rate limiting | Per-wallet message rate limiting with 429 responses | +| Encrypted file references | Infrastructure and API routes for encrypted file metadata | +| Responsive UI | Mobile-first design, dark/light mode | +| WebSocket support | Real-time connection layer alongside Supabase Realtime | +| CI pipeline | Lint + build on every pull request via GitHub Actions | + +### 🚧 Planned / not yet implemented + +| Feature | Status | +|---|---| +| End-to-end message encryption | Infrastructure exists (`is_encrypted` flag), client-side encryption not wired up | +| Encrypted file sharing (complete) | API routes exist, upload/download UI not built | +| DAO-based moderation | Dispute resolution is manual; on-chain DAO voting not implemented | +| Group ownership via Stellar accounts | Metadata anchoring works; full on-chain ownership model not implemented | +| Mobile PWA | Responsive design works; PWA manifest not configured | +| Message pagination UI | API supports cursor-based pagination; infinite scroll not built | --- ## 🧩 Core Features -### 🔒 Complete Anonymity - -* No usernames, emails, or profile data -* No tracking or surveillance -* Zero-knowledge architecture - -### 🔐 End-to-End Encryption - -* Messages are encrypted client-side -* Only chat participants can read messages - -### 🌐 Decentralized Groups - -* Create or join anonymous chat rooms -* No central authority or moderation bias - ### 👛 Web3 Wallet Authentication -* Login using a supported Web3 wallet -* Wallet address acts as a **pseudonymous identity** - -### ⚡ Lightning Fast Messaging - -* Real-time chat with minimal latency +Connect with any Stellar-compatible wallet (Freighter, xBull, Lobstr, etc.). The server issues a one-time nonce, your wallet signs it, and the signature is verified server-side using Ed25519 — no password ever touches the server. -### 🛡 Privacy First - -* No IP logging -* No data selling or analytics exploitation - ---- - -## 🏗️ Tech Stack +### 🌐 Anonymous Chat Rooms -### Frontend +Create or join public chat rooms. Your wallet address is your pseudonymous identity. No display name, no avatar, no profile. -* **Next.js / React** -* **Tailwind CSS** -* **Web3 Wallet Integration** +### ⭐ Stellar Blockchain Anchoring -### Blockchain +When you create a group, its metadata is hashed (SHA-256) and submitted to the Stellar network as a self-payment transaction memo. Anyone can independently verify the group's integrity on-chain. -* **Stellar Blockchain** ⭐ -* Wallet-based authentication -* Public-key cryptography +### 🗳️ Wallet-Based Member Removal -### Backend +Room members can vote to remove another member. When a majority votes, the member is removed and can no longer send messages in that room. -* Node.js / Serverless APIs -* WebSocket / Real-time messaging -* Encrypted message storage +### 💰 Escrow System -### Hosting - -* **Vercel** +A full escrow lifecycle built on Stellar: create, fund (on-chain payment), release to beneficiary, refund to initiator, raise a dispute, or resolve it. --- -## 🔐 Security Model +## 🏗️ Tech Stack -* End-to-end encrypted messages -* Zero-knowledge design -* Decentralized architecture -* Open-source codebase (auditable) -* No identity or metadata storage +| Layer | Technology | +|---|---| +| Frontend | Next.js 16, React 19, TypeScript 5, Tailwind CSS 4 | +| UI Components | Radix UI primitives (30+ components) | +| Web3 | Stellar Wallets Kit (`@creit.tech/stellar-wallets-kit`) | +| Auth | Supabase Auth + Stellar Ed25519 signature verification | +| Database | Supabase (PostgreSQL) with Row-Level Security | +| Real-time | Supabase Realtime + WebSocket server | +| Blockchain | Stellar SDK v13 (`@stellar/stellar-sdk`) | +| Hosting | Vercel | +| CI/CD | GitHub Actions | --- - - ## 🏛️ Architecture ```mermaid flowchart TB subgraph Client["👤 Client"] - Wallet[Web3 Wallet] + Wallet[Stellar Wallet\nFreighter / xBull / Lobstr] Browser[Browser] end - subgraph Frontend["⚛️ Frontend (Next.js)"] + subgraph Frontend["⚛️ Next.js App"] UI[React Components] - Auth[Auth Module] + Auth[Wallet Auth Module] Chat[Chat Interface] end - subgraph Backend["🔧 Backend Services"] - Supabase[(Supabase)] + subgraph Backend["🔧 API Routes"] + AuthAPI[/api/auth] + RoomsAPI[/api/rooms] + MessagesAPI[/api/messages] + EscrowAPI[/api/escrow] + StellarAPI[/api/stellar] + end + + subgraph Data["💾 Supabase"] + DB[(PostgreSQL)] Realtime[Realtime Engine] + RLS[Row-Level Security] end - subgraph Blockchain["⭐ Stellar"] - StellarNet[Stellar Network] + subgraph Blockchain["⭐ Stellar Network"] + Horizon[Horizon API] + Testnet[Testnet / Mainnet] end - Wallet -->|Sign Auth| Auth + Wallet -->|Sign nonce| Auth Browser --> UI UI --> Chat - Auth -->|Verify| Supabase - Chat -->|Messages| Realtime - Realtime -->|Sync| Supabase - Auth -.->|Wallet Auth| StellarNet + Auth --> AuthAPI + Chat --> MessagesAPI + Chat --> RoomsAPI + RoomsAPI --> StellarAPI + EscrowAPI --> Horizon + StellarAPI --> Horizon + AuthAPI --> DB + MessagesAPI --> Realtime + Realtime --> DB + DB --> RLS ``` -| Layer | Technology | -|-------|------------| -| Frontend | Next.js 16, React 19, TypeScript, Tailwind CSS | -| Auth | Supabase Auth + Web3 Wallet | -| Database | Supabase (PostgreSQL) | -| Real-time | Supabase Realtime | -| Blockchain | Stellar Network | -| Hosting | Vercel | - --- -## 🛠️ Quick Start +## 🛠️ Getting Started ### Prerequisites -* Node.js >= 18.x -* pnpm (recommended) -* [Supabase account](https://supabase.com) +- **Node.js** >= 20.9.0 — [nodejs.org](https://nodejs.org) +- **npm** (comes with Node) or **pnpm** >= 8 +- **Supabase account** — [supabase.com](https://supabase.com) (free tier works) +- **Stellar testnet account** — get one free at [Stellar Laboratory](https://laboratory.stellar.org/#account-creator?network=test) +- **Freighter wallet** browser extension — [freighter.app](https://freighter.app) (for testing) -### Setup +### 1. Clone and install ```bash -# 1. Clone and install -git clone https://github.com/your-username/anonchat.git +git clone https://github.com/your-username/AnonChat.git cd AnonChat -pnpm install - -# 2. Configure environment -cp .env.example .env.local -# Edit .env.local with your Supabase credentials - -# 3. Run database migrations in Supabase SQL Editor -# scripts/001_create_profiles.sql -# scripts/002_create_profile_trigger.sql -# scripts/003_room_members_and_removal_votes.sql (for wallet-based removal voting) - -# 4. Start dev server -pnpm dev +npm install ``` -### Testing wallet-based removal voting - -**Full runbook:** See [docs/RUN-VOTE-REMOVE.md](docs/RUN-VOTE-REMOVE.md) for step-by-step run and verify instructions. +> The project uses `npm` as its primary package manager. `pnpm` also works — use `pnpm install` if you prefer. -1. **Apply the migration** - Run `scripts/003_room_members_and_removal_votes.sql` in the Supabase SQL Editor so `room_members`, `room_removal_votes`, and `check_removal_threshold` exist. +### 2. Create a Supabase project -2. **API smoke test** (Node ≥18, dev server on Node ≥20): - With the dev server running (`pnpm dev`), in another terminal: - ```bash - pnpm run test:vote-remove - ``` - This checks that unauthenticated requests get 401 and invalid requests get 400. +1. Go to [supabase.com](https://supabase.com) and create a new project. +2. In **Settings → API**, copy: + - **Project URL** → `NEXT_PUBLIC_SUPABASE_URL` + - **anon / public key** → `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - **service_role key** → `SUPABASE_SERVICE_ROLE_KEY` -3. **Manual UI test** - Open `/chat`, select a room, click the ⋮ (More) button in the header. The “Room members & voting” dialog should open; without auth you’ll see “No members yet, or you need to sign in.” After signing in and joining a room (e.g. by sending a message), you can vote to remove another member; when a majority votes, they are removed and can no longer send messages in that room. +### 3. Configure environment variables -### Environment Variables +Create `.env.local` in the project root: ```env -NEXT_PUBLIC_SUPABASE_URL=your-project-url +# Supabase (required) +NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# Stellar (required for blockchain features) +STELLAR_NETWORK=testnet +STELLAR_SOURCE_SECRET=S...your-testnet-secret-key... +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +STELLAR_TRANSACTION_TIMEOUT=30000 + +# App NEXT_PUBLIC_STELLAR_NETWORK=testnet NEXT_PUBLIC_APP_NAME=AnonChat ``` -> Find credentials in Supabase Dashboard → Settings → API +> **Never commit `.env.local`** — it is already in `.gitignore`. +> For `STELLAR_SOURCE_SECRET`, create a funded testnet account at [Stellar Laboratory](https://laboratory.stellar.org/#account-creator?network=test) and fund it with the Friendbot. + +### 4. Run database migrations + +Open the **Supabase SQL Editor** (Dashboard → SQL Editor) and run each script in order: + +``` +scripts/001_create_profiles.sql +scripts/002_create_profile_trigger.sql +scripts/003_create_invites.sql +scripts/004_create_room_members.sql +scripts/005_add_last_read_to_room_members.sql +scripts/006_unread_view.sql +scripts/007_create_group_membership.sql +scripts/007_secure_messages_rls.sql +scripts/008_create_groups.sql +scripts/009_encrypted_file_references.sql +scripts/010_message_status.sql +scripts/011_group_tx_memo_map.sql +scripts/012_escrow_tables.sql +``` + +Or, if you have direct database access via `psql`: + +```bash +export DATABASE_URL="postgresql://postgres:@:5432/postgres" +for f in scripts/0*.sql; do psql "$DATABASE_URL" -f "$f"; done +``` + +### 5. Start the development server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). The app is ready. + +To also run the WebSocket server alongside Next.js: + +```bash +npm run dev:all +``` + +### 6. Verify the setup + +1. Open the app and click **Connect** in the header. +2. Approve the connection in Freighter. +3. Sign the authentication message when prompted. +4. You should see your wallet address displayed and a success toast. +5. Navigate to `/chat` and create a group — you'll see the estimated Stellar network fee before confirming. + +--- + +## 🧪 Testing + +```bash +# Lint +npm run lint + +# Production build (catches type errors) +npm run build + +# API smoke tests for member removal voting +npm run test:vote-remove + +# WebSocket connectivity check +npm test +``` + +The CI pipeline runs lint and build on every pull request. See `.github/workflows/ci.yml`. --- -## 🧪 Roadmap +## 🐳 Docker -* [ ] Group ownership via Stellar accounts -* [ ] On-chain group identity -* [ ] DAO-based moderation -* [ ] Encrypted file sharing -* [ ] Mobile PWA support +```bash +docker build -t anonchat . +docker run -p 3000:3000 --env-file .env.local anonchat +``` --- -## 🤝 Contributing +## 🗺️ Roadmap -See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Quick steps: +- [ ] Client-side end-to-end message encryption +- [ ] Encrypted file sharing (upload/download UI) +- [ ] DAO-based moderation (on-chain dispute resolution) +- [ ] Group ownership via Stellar accounts +- [ ] Mobile PWA support +- [ ] Message pagination UI (infinite scroll) -1. Fork → Create branch `fix-[issue-number]` → Make changes → Test → PR -2. **Important**: Only submit PRs for issues you're assigned to +--- + +## 📖 Documentation + +- **[User Guide](docs/user-guide.md)** — Step-by-step guide for new users: installing Freighter, funding a testnet account, connecting, creating groups, and chatting. +- **[Setup Guide](SETUP.md)** — Detailed local development setup. +- **[Contributing](CONTRIBUTING.md)** — How to contribute. +- **[WebSocket Integration](WEBSOCKET_INTEGRATION.md)** — WebSocket server details. +- **[Database Migrations](scripts/MIGRATIONS_README.md)** — Migration order and instructions. --- -## 📜 License +## 🤝 Contributing -This project is licensed under the **MIT License**. +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +1. Fork → create branch `fix-[issue-number]` or `feat-[description]` +2. Make changes → run `npm run lint` and `npm run build` +3. Open a PR — only submit PRs for issues you're assigned to --- -## 🌐 Live Demo +## 📜 License -🔗 [https://anonchat-one.vercel.app](https://anonchat-one.vercel.app) +MIT — see [LICENSE](LICENSE). --- ## 💜 Credits -Built with privacy in mind and powered by **Stellar Blockchain**. +Built with privacy in mind, powered by the **Stellar Blockchain**. > If you believe communication should be free, anonymous, and decentralized — AnonChat is for you. ---- - -### ⭐ Don’t forget to star the repository if you like the project! +⭐ Star the repo if you find it useful! diff --git a/components/footer.tsx b/components/footer.tsx index 8eea08b..14f539f 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -30,78 +30,89 @@ export function Footer() {

Product

  • - + Features
  • - - Pricing + + Security
  • - - Security + + Start Chatting
  • - + Roadmap - +
- {/* Company */} + {/* Docs */}
-

Company

+

Docs

  • - - About - + + User Guide +
  • - - Blog - + + Setup Guide +
  • - - Community - -
  • -
  • - - Contact - + + Contributing +
- {/* Legal */} + {/* Community */}
-

Legal

+

Community

  • - - Privacy - + + GitHub +
  • - - Terms - + + Report an Issue +
  • - - Cookies - -
  • -
  • - - Licenses - + + Contribute +
diff --git a/components/hero.tsx b/components/hero.tsx index e2e5ab9..7fe807a 100644 --- a/components/hero.tsx +++ b/components/hero.tsx @@ -53,13 +53,19 @@ export function Hero() {
+ Start Chatting Now + + + + + - -
{/* Stats */} diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..80aa2da --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,265 @@ +# AnonChat User Guide + +Welcome to AnonChat — an anonymous, Stellar-powered chat platform. This guide walks you through everything you need to get started, from installing a wallet to chatting in your first group. + +**No email. No phone number. No identity required.** + +--- + +## Table of Contents + +1. [What you need](#1-what-you-need) +2. [Install the Freighter wallet](#2-install-the-freighter-wallet) +3. [Create and fund a testnet account](#3-create-and-fund-a-testnet-account) +4. [Connect your wallet to AnonChat](#4-connect-your-wallet-to-anonchat) +5. [Navigate the chat interface](#5-navigate-the-chat-interface) +6. [Create a group](#6-create-a-group) +7. [Join an existing group](#7-join-an-existing-group) +8. [Send and receive messages](#8-send-and-receive-messages) +9. [View group members](#9-view-group-members) +10. [Disconnect your wallet](#10-disconnect-your-wallet) +11. [Troubleshooting](#11-troubleshooting) + +--- + +## 1. What you need + +| Requirement | Details | +|---|---| +| Browser | Chrome, Brave, Firefox, or Edge (desktop) | +| Wallet extension | Freighter (recommended), xBull, or Lobstr | +| Stellar account | A testnet account funded with test XLM | +| Internet connection | Required for real-time messaging and blockchain operations | + +> **Mainnet vs. Testnet:** The live demo runs on Stellar **testnet**. Testnet XLM has no real-world value — it's free and safe to experiment with. + +--- + +## 2. Install the Freighter wallet + +Freighter is a browser extension wallet for the Stellar network. It's the easiest way to authenticate with AnonChat. + +**Steps:** + +1. Go to [freighter.app](https://freighter.app) and click **Add to Chrome** (or your browser). +2. Follow the browser prompt to install the extension. +3. Click the Freighter icon in your browser toolbar to open it. +4. Choose **Create new wallet**. +5. Write down your **12-word recovery phrase** and store it somewhere safe — this is the only way to recover your wallet. +6. Set a password for the extension. +7. Your wallet is ready. You'll see your Stellar public key (starts with `G...`) on the home screen. + +> **Other supported wallets:** xBull ([xbull.app](https://xbull.app)) and Lobstr ([lobstr.co](https://lobstr.co)) also work with AnonChat. The connection flow is the same. + +--- + +## 3. Create and fund a testnet account + +Your Freighter wallet generates a Stellar keypair, but the account doesn't exist on the network until it's funded. On testnet, you can get free XLM instantly. + +**Steps:** + +1. Open Freighter and switch to **Testnet**: + - Click the network name at the top (it may say "Mainnet"). + - Select **Test SDF Network / Testnet**. + +2. Copy your public key from Freighter (the `G...` address). + +3. Open [Stellar Laboratory — Friendbot](https://laboratory.stellar.org/#account-creator?network=test). + +4. Paste your public key into the **Public Key** field and click **Get test network lumens**. + +5. You'll receive **10,000 test XLM** — more than enough to use AnonChat. + +6. Back in Freighter, your balance should now show `10,000 XLM`. + +> **Why do I need XLM?** Creating a group on AnonChat submits a small transaction to the Stellar network to anchor the group's metadata. The fee is tiny (around 0.0000100 XLM), but your account must exist on-chain first. + +--- + +## 4. Connect your wallet to AnonChat + +**Steps:** + +1. Open [AnonChat](https://anonchat-one.vercel.app) in your browser. + +2. Click the **Connect** button in the top-right corner of the header. + +3. A wallet selection dialog appears. Choose **Freighter** (or your preferred wallet). + +4. Freighter will ask you to approve the connection — click **Connect**. + +5. AnonChat will request a **signature** to verify you own the wallet: + - A toast notification appears: *"Sign the message in your wallet to verify ownership…"* + - Freighter opens automatically with a signing request. + - Review the message (it will look like `anonchat:1234567890:uuid`) and click **Sign**. + +6. On success, your wallet address appears in the header (shortened, e.g. `GABC...XYZ`). + - First-time users see: *"Wallet verified & account created!"* + - Returning users see: *"Wallet verified — welcome back!"* + +> **What just happened?** The server generated a one-time nonce, you signed it with your private key, and the server verified the signature using your public key. No password was ever sent. This is the entire authentication flow. + +--- + +## 5. Navigate the chat interface + +After connecting, click **Start Chatting Now** on the landing page, or go directly to [/chat](https://anonchat-one.vercel.app/chat). + +The chat interface has two main areas: + +**Left sidebar — Room list** +- Shows all groups you have access to. +- Displays the last message preview and unread count for each room. +- Use the search bar at the top to filter rooms by name. +- **Create Group** and **Join Group** buttons are at the top of the sidebar. + +**Right panel — Conversation** +- Shows messages for the selected room. +- Type in the input at the bottom and press **Enter** or click the send button. +- Message status indicators: sending → sent → delivered → read. + +**On mobile:** The sidebar and conversation panel stack. Use the back arrow to return to the room list. + +--- + +## 6. Create a group + +Creating a group anchors its metadata on the Stellar blockchain. A small network fee applies. + +**Steps:** + +1. In the chat sidebar, click **Create Group**. + +2. If your wallet isn't connected yet, the modal will prompt you to connect first. + +3. Enter a **Group Name** (e.g. "Shadow Explorers"). + +4. The modal shows your connected wallet address and the **estimated network fee** (fetched live from Stellar, typically ~0.0000100 XLM). + +5. Click **Create Group**. + +6. Wait a few seconds while the transaction is submitted to Stellar. + +7. On success, a toast confirms: *"Group 'Shadow Explorers' created successfully! Network charged: 0.0000100 XLM."* + +8. The new group appears in your room list immediately. + +> **What gets anchored on-chain?** A SHA-256 hash of the group's metadata (name, description, creator, timestamp) is embedded in a Stellar transaction memo. Anyone can verify the group's integrity by checking this hash against the blockchain. + +--- + +## 7. Join an existing group + +You can join a group using either an **invite code** or the **group ID**. + +**Steps:** + +1. Click **Join Group** in the chat sidebar. + +2. Choose your method: + - **Invite Code** — paste a code shared by a group member (format: `X7b-tnk-...`) + - **Group ID** — paste the room's UUID directly + +3. Enter the code or ID and click **Join Group**. + +4. On success, the group appears in your room list. + +> **Getting an invite code:** Currently, group creators share their group ID directly. Copy it from the room details or ask the group creator to share it with you. + +--- + +## 8. Send and receive messages + +**Sending a message:** + +1. Select a room from the sidebar. +2. Click the message input at the bottom of the conversation panel. +3. Type your message and press **Enter** or click the **send** button (arrow icon). +4. Your message appears immediately with a "sending" indicator, then updates to "sent" once confirmed. + +**Message status:** +- ⏳ **Sending** — being submitted to the server +- ✓ **Sent** — saved to the database +- ✓✓ **Delivered** — received by the room +- ✓✓ **Read** — seen by other members + +**Real-time updates:** Messages from other members appear instantly via Supabase Realtime — no need to refresh. + +> **Rate limiting:** To prevent spam, there is a per-wallet message rate limit. If you send too many messages too quickly, you'll receive a "rate limit exceeded" error. Wait a moment and try again. + +--- + +## 9. View group members + +1. Open a room in the conversation panel. +2. Click the **Users** icon (👥) in the room header, or the **⋮** (more options) button. +3. The **Room Members** dialog opens, showing all current members by their wallet address. + +**Voting to remove a member:** + +If a member is being disruptive, other members can vote to remove them: + +1. In the Room Members dialog, find the member you want to remove. +2. Click **Vote to Remove** next to their address. +3. When a majority of members have voted to remove the same member, they are automatically removed from the room and can no longer send messages. + +> Removal is wallet-based — the removed wallet address is blocked from the room. The member can still read public rooms but cannot post. + +--- + +## 10. Disconnect your wallet + +1. Click your wallet address in the header. +2. Click **Disconnect**. +3. Your session ends and the wallet address is cleared from the UI. + +You can reconnect at any time by clicking **Connect** again. Your rooms and message history are preserved. + +--- + +## 11. Troubleshooting + +### "Failed to get nonce" or "Authentication failed" + +- Make sure Freighter is unlocked (enter your extension password if prompted). +- Check that you're on the correct network (Testnet for the demo). +- Try refreshing the page and connecting again. + +### Wallet connects but no rooms appear + +- Your account may not have joined any rooms yet. Click **Join Group** or **Create Group**. +- Check your browser console for errors and ensure `NEXT_PUBLIC_SUPABASE_URL` is set correctly (for self-hosted instances). + +### "Failed to create group" / transaction error + +- Ensure your testnet account has XLM. Re-fund it at [Stellar Friendbot](https://laboratory.stellar.org/#account-creator?network=test). +- The Stellar network fee is tiny but your account must have a non-zero balance. +- Check that `STELLAR_SOURCE_SECRET` is set in your environment (for self-hosted instances). + +### Messages not appearing in real-time + +- Check your internet connection. +- Supabase Realtime requires WebSocket support. Make sure your browser or network isn't blocking WebSocket connections. +- Try refreshing the page. + +### Freighter doesn't open for signing + +- Make sure the Freighter extension is installed and unlocked. +- Some browsers block extension popups — check your browser's popup settings and allow popups from the AnonChat domain. + +### I lost my recovery phrase + +- Unfortunately, there is no way to recover a Stellar wallet without the recovery phrase. Create a new wallet and fund it again from Friendbot. + +--- + +## Need help? + +- Open an issue on [GitHub](https://github.com/your-username/AnonChat/issues) +- See the [README](../README.md) for project overview and setup instructions +- See [SETUP.md](../SETUP.md) for developer setup + +--- + +*AnonChat collects no personal data. Your wallet address is your only identity on the platform.* From a09284955d17c02d3d4eae9cf2130ace59e7015d Mon Sep 17 00:00:00 2001 From: pugsley76 Date: Sun, 26 Apr 2026 11:24:16 -0700 Subject: [PATCH 3/4] docs: update README, add .env.example, fix migrations list - Rewrite README with accurate Getting Started steps, correct migration list (all 15 files), honest Testing section, Screenshots placeholder, exact version numbers in tech stack, and badge setup note - Add .env.example so contributors know required env vars - Update scripts/MIGRATIONS_README.md to list all 15 migrations (was outdated, only listed up to 007) - Fix .gitignore to track .env.example while still ignoring .env.local --- .env.example | 29 +++++++ .gitignore | 1 + README.md | 144 ++++++++++++++++++++++++----------- scripts/MIGRATIONS_README.md | 99 ++++++++++++++---------- 4 files changed, 188 insertions(+), 85 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f80cba0 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# ───────────────────────────────────────────────────────────────────────────── +# AnonChat — Environment Variables +# Copy this file to .env.local and fill in your values. +# NEVER commit .env.local — it is already in .gitignore. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Supabase (required) ─────────────────────────────────────────────────────── +# Find these in your Supabase project: Settings → API +NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# ── Stellar (required for blockchain features) ──────────────────────────────── +# STELLAR_NETWORK: "testnet" or "mainnet" +STELLAR_NETWORK=testnet +# STELLAR_SOURCE_SECRET: secret key of the service wallet that signs transactions +# Create a funded testnet account at https://laboratory.stellar.org/#account-creator?network=test +STELLAR_SOURCE_SECRET=S...your-testnet-secret-key... +# Horizon API endpoint — use the testnet URL below for local development +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org +# Transaction timeout in milliseconds (default: 30000) +STELLAR_TRANSACTION_TIMEOUT=30000 + +# ── App ─────────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_STELLAR_NETWORK=testnet +NEXT_PUBLIC_APP_NAME=AnonChat + +# ── Redis (optional — rate limiting falls back to in-memory without this) ───── +# REDIS_URL=redis://localhost:6379 diff --git a/.gitignore b/.gitignore index e484cc6..ae8bcda 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ yarn-error.log* # env files .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index f28b1a0..c1edb89 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,22 @@ > Speak freely. Stay anonymous. Powered by Stellar. -[![CI](https://github.com/your-username/AnonChat/actions/workflows/ci.yml/badge.svg)](https://github.com/your-username/AnonChat/actions/workflows/ci.yml) + +[![CI](https://github.com/your-org/AnonChat/actions/workflows/ci.yml/badge.svg)](https://github.com/your-org/AnonChat/actions/workflows/ci.yml) [![Vercel](https://img.shields.io/badge/deployed-Vercel-black?logo=vercel)](https://anonchat-one.vercel.app) [![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D20.9.0-brightgreen)](https://nodejs.org) +> **Badge setup:** After pushing to GitHub, replace `your-org/AnonChat` in the CI badge URL above with your actual GitHub username/org and repository name. The Vercel badge will update automatically once the project is deployed. + --- ## 🌐 Live Demo 🔗 **[https://anonchat-one.vercel.app](https://anonchat-one.vercel.app)** +> If the demo is unavailable, the app requires a live Supabase project and Stellar testnet account — see [Getting Started](#️-getting-started) to run it locally. + --- ## 📋 Current Status @@ -31,21 +36,33 @@ | Member management | View members, vote to remove members (majority threshold) | | Escrow system | Full lifecycle: create → fund → release / refund / dispute → resolve | | Rate limiting | Per-wallet message rate limiting with 429 responses | -| Encrypted file references | Infrastructure and API routes for encrypted file metadata | +| Encrypted file references | API routes and DB schema for encrypted file metadata | | Responsive UI | Mobile-first design, dark/light mode | -| WebSocket support | Real-time connection layer alongside Supabase Realtime | +| WebSocket server | Real-time presence and typing indicators alongside Supabase Realtime | | CI pipeline | Lint + build on every pull request via GitHub Actions | ### 🚧 Planned / not yet implemented -| Feature | Status | +| Feature | Notes | |---|---| -| End-to-end message encryption | Infrastructure exists (`is_encrypted` flag), client-side encryption not wired up | -| Encrypted file sharing (complete) | API routes exist, upload/download UI not built | -| DAO-based moderation | Dispute resolution is manual; on-chain DAO voting not implemented | +| End-to-end message encryption | DB flag (`is_encrypted`) and schema exist; client-side encryption not wired up | +| Encrypted file sharing (UI) | API routes exist; upload/download UI not built | +| DAO-based moderation | Dispute resolution is currently manual; on-chain DAO voting not implemented | | Group ownership via Stellar accounts | Metadata anchoring works; full on-chain ownership model not implemented | | Mobile PWA | Responsive design works; PWA manifest not configured | | Message pagination UI | API supports cursor-based pagination; infinite scroll not built | +| Test coverage reporting | Unit test file exists (`lib/auth/stellar-verify.test.ts`); no test runner (Jest/Vitest) is configured yet | + +--- + +## 📸 Screenshots + +> Screenshots will be added once the live deployment is stable. To preview the UI locally, follow the [Getting Started](#️-getting-started) steps and open `http://localhost:3000`. + +**To contribute screenshots:** +1. Run the app locally (`npm run dev`) +2. Take screenshots of: landing page, wallet connect flow, chat room list, active chat, create group modal +3. Save them to `public/screenshots/` and open a PR --- @@ -69,7 +86,7 @@ Room members can vote to remove another member. When a majority votes, the membe ### 💰 Escrow System -A full escrow lifecycle built on Stellar: create, fund (on-chain payment), release to beneficiary, refund to initiator, raise a dispute, or resolve it. +A full escrow lifecycle built on Stellar: create, fund (on-chain payment), release to beneficiary, refund to initiator, raise a dispute, or resolve it. No smart contracts — escrow state is tracked in Supabase and settled via Stellar payments from the service wallet. --- @@ -77,13 +94,14 @@ A full escrow lifecycle built on Stellar: create, fund (on-chain payment), relea | Layer | Technology | |---|---| -| Frontend | Next.js 16, React 19, TypeScript 5, Tailwind CSS 4 | -| UI Components | Radix UI primitives (30+ components) | -| Web3 | Stellar Wallets Kit (`@creit.tech/stellar-wallets-kit`) | +| Frontend | Next.js 16.0.10, React 19.2.0, TypeScript 5, Tailwind CSS 4 | +| UI Components | Radix UI primitives (30+ components), shadcn/ui | +| Web3 | Stellar Wallets Kit v1.9.5 (`@creit.tech/stellar-wallets-kit`) | | Auth | Supabase Auth + Stellar Ed25519 signature verification | | Database | Supabase (PostgreSQL) with Row-Level Security | -| Real-time | Supabase Realtime + WebSocket server | -| Blockchain | Stellar SDK v13 (`@stellar/stellar-sdk`) | +| Real-time | Supabase Realtime + WebSocket server (ws v8.19.0) | +| Blockchain | Stellar SDK v13.3.0 (`@stellar/stellar-sdk`) | +| Rate limiting | Redis v4.7.0 (optional; falls back to in-memory) | | Hosting | Vercel | | CI/CD | GitHub Actions | @@ -145,20 +163,20 @@ flowchart TB ### Prerequisites - **Node.js** >= 20.9.0 — [nodejs.org](https://nodejs.org) -- **npm** (comes with Node) or **pnpm** >= 8 +- **npm** (bundled with Node) or **pnpm** >= 8 - **Supabase account** — [supabase.com](https://supabase.com) (free tier works) -- **Stellar testnet account** — get one free at [Stellar Laboratory](https://laboratory.stellar.org/#account-creator?network=test) -- **Freighter wallet** browser extension — [freighter.app](https://freighter.app) (for testing) +- **Stellar testnet account** — create and fund one at [Stellar Laboratory](https://laboratory.stellar.org/#account-creator?network=test) +- **Freighter wallet** browser extension — [freighter.app](https://freighter.app) (for testing auth) ### 1. Clone and install ```bash -git clone https://github.com/your-username/AnonChat.git +git clone https://github.com/your-org/AnonChat.git cd AnonChat npm install ``` -> The project uses `npm` as its primary package manager. `pnpm` also works — use `pnpm install` if you prefer. +> `pnpm install` also works if you prefer pnpm. ### 2. Create a Supabase project @@ -170,7 +188,13 @@ npm install ### 3. Configure environment variables -Create `.env.local` in the project root: +Copy the example file and fill in your values: + +```bash +cp .env.example .env.local +``` + +Then edit `.env.local`: ```env # Supabase (required) @@ -187,19 +211,28 @@ STELLAR_TRANSACTION_TIMEOUT=30000 # App NEXT_PUBLIC_STELLAR_NETWORK=testnet NEXT_PUBLIC_APP_NAME=AnonChat + +# Redis (optional — rate limiting falls back to in-memory without this) +# REDIS_URL=redis://localhost:6379 ``` > **Never commit `.env.local`** — it is already in `.gitignore`. -> For `STELLAR_SOURCE_SECRET`, create a funded testnet account at [Stellar Laboratory](https://laboratory.stellar.org/#account-creator?network=test) and fund it with the Friendbot. +> For `STELLAR_SOURCE_SECRET`, create a funded testnet account at [Stellar Laboratory](https://laboratory.stellar.org/#account-creator?network=test) and fund it via Friendbot. ### 4. Run database migrations -Open the **Supabase SQL Editor** (Dashboard → SQL Editor) and run each script in order: +The `scripts/` folder contains 15 SQL migration files. Run them in the order below — the numeric prefix determines the order, but note that some share the same prefix and must both be applied. + +**Option A — Supabase SQL Editor** (Dashboard → SQL Editor → New query): + +Run each file in this order: ``` scripts/001_create_profiles.sql scripts/002_create_profile_trigger.sql scripts/003_create_invites.sql +scripts/003_add_blockchain_fields.sql +scripts/003_room_members_and_removal_votes.sql scripts/004_create_room_members.sql scripts/005_add_last_read_to_room_members.sql scripts/006_unread_view.sql @@ -212,54 +245,76 @@ scripts/011_group_tx_memo_map.sql scripts/012_escrow_tables.sql ``` -Or, if you have direct database access via `psql`: +**Option B — psql** (if you have direct database access): ```bash export DATABASE_URL="postgresql://postgres:@:5432/postgres" -for f in scripts/0*.sql; do psql "$DATABASE_URL" -f "$f"; done + +for f in \ + scripts/001_create_profiles.sql \ + scripts/002_create_profile_trigger.sql \ + scripts/003_create_invites.sql \ + scripts/003_add_blockchain_fields.sql \ + scripts/003_room_members_and_removal_votes.sql \ + scripts/004_create_room_members.sql \ + scripts/005_add_last_read_to_room_members.sql \ + scripts/006_unread_view.sql \ + scripts/007_create_group_membership.sql \ + scripts/007_secure_messages_rls.sql \ + scripts/008_create_groups.sql \ + scripts/009_encrypted_file_references.sql \ + scripts/010_message_status.sql \ + scripts/011_group_tx_memo_map.sql \ + scripts/012_escrow_tables.sql; do + psql "$DATABASE_URL" -f "$f" +done ``` +> The Supabase connection string is in your project: **Settings → Database → Connection string**. + ### 5. Start the development server ```bash +# Next.js only (port 3000) npm run dev -``` -Open [http://localhost:3000](http://localhost:3000). The app is ready. - -To also run the WebSocket server alongside Next.js: - -```bash +# Next.js + WebSocket server (ports 3000 and 3001) npm run dev:all ``` +Open [http://localhost:3000](http://localhost:3000). + ### 6. Verify the setup -1. Open the app and click **Connect** in the header. +1. Click **Connect** in the header. 2. Approve the connection in Freighter. 3. Sign the authentication message when prompted. -4. You should see your wallet address displayed and a success toast. -5. Navigate to `/chat` and create a group — you'll see the estimated Stellar network fee before confirming. +4. Your wallet address should appear in the header — that confirms auth is working. +5. Navigate to `/chat` and create a group. You'll see the estimated Stellar network fee before confirming. --- ## 🧪 Testing +There is no automated test runner configured yet. The following commands are available: + ```bash -# Lint +# ESLint — catches code quality issues npm run lint -# Production build (catches type errors) +# TypeScript compilation — catches type errors across the whole project npm run build -# API smoke tests for member removal voting -npm run test:vote-remove - -# WebSocket connectivity check +# WebSocket connectivity check — requires the WS server to be running npm test + +# API smoke tests for member removal voting — requires a running dev server +npm run test:vote-remove ``` -The CI pipeline runs lint and build on every pull request. See `.github/workflows/ci.yml`. +A unit test file exists at `lib/auth/stellar-verify.test.ts` (covers Ed25519 signature verification and nonce management) but requires a test runner (Jest or Vitest) to be wired up. See the [roadmap](#️-roadmap) — adding a proper test runner and CI coverage reporting is a planned improvement. + +The CI pipeline (`.github/workflows/ci.yml`) runs `npm run lint` and `npm run build` on every pull request. --- @@ -280,16 +335,19 @@ docker run -p 3000:3000 --env-file .env.local anonchat - [ ] Group ownership via Stellar accounts - [ ] Mobile PWA support - [ ] Message pagination UI (infinite scroll) +- [ ] Wire up Jest/Vitest test runner and add CI coverage reporting +- [ ] Screenshots in README --- ## 📖 Documentation -- **[User Guide](docs/user-guide.md)** — Step-by-step guide for new users: installing Freighter, funding a testnet account, connecting, creating groups, and chatting. +- **[User Guide](docs/user-guide.md)** — Installing Freighter, funding a testnet account, connecting, creating groups, and chatting. - **[Setup Guide](SETUP.md)** — Detailed local development setup. - **[Contributing](CONTRIBUTING.md)** — How to contribute. -- **[WebSocket Integration](WEBSOCKET_INTEGRATION.md)** — WebSocket server details. -- **[Database Migrations](scripts/MIGRATIONS_README.md)** — Migration order and instructions. +- **[WebSocket Integration](WEBSOCKET_INTEGRATION.md)** — WebSocket server architecture and message protocol. +- **[WebSocket Examples](WEBSOCKET_EXAMPLES.md)** — Code examples for connecting to the WebSocket server. +- **[Database Migrations](scripts/MIGRATIONS_README.md)** — Migration notes (note: this file lists only migrations up to 007; the full list is in this README). --- @@ -297,7 +355,7 @@ docker run -p 3000:3000 --env-file .env.local anonchat See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. -1. Fork → create branch `fix-[issue-number]` or `feat-[description]` +1. Fork → create a branch: `fix/issue-number` or `feat/description` 2. Make changes → run `npm run lint` and `npm run build` 3. Open a PR — only submit PRs for issues you're assigned to diff --git a/scripts/MIGRATIONS_README.md b/scripts/MIGRATIONS_README.md index 5324787..d63f979 100644 --- a/scripts/MIGRATIONS_README.md +++ b/scripts/MIGRATIONS_README.md @@ -1,56 +1,71 @@ -# Migration files and instructions +# Database Migrations -This repo includes SQL migration scripts in the `scripts/` folder. If you open a PR that adds or modifies these SQL files, please ask the repository maintainers (or CI) to apply them to the database in order. +SQL migration scripts live in `scripts/`. Apply them in the order listed below — the numeric prefix determines the order. Some prefixes have multiple files; apply all of them before moving to the next number. -Included migrations (apply in numeric order): +## Migration order -- `001_create_profiles.sql` -- `002_create_profile_trigger.sql` -- `003_create_invites.sql` (new) -- `004_create_room_members.sql` (new) -- `005_add_last_read_to_room_members.sql` (new) -- `006_unread_view.sql` (new) -- `007_create_group_membership.sql` (new) +| File | Description | +|---|---| +| `001_create_profiles.sql` | User profiles table | +| `002_create_profile_trigger.sql` | Auto-create profile on auth.users insert | +| `003_create_invites.sql` | Group invite system | +| `003_add_blockchain_fields.sql` | Blockchain metadata fields on rooms | +| `003_room_members_and_removal_votes.sql` | Room membership and vote-to-remove tables | +| `004_create_room_members.sql` | Room members table (RLS policies) | +| `005_add_last_read_to_room_members.sql` | `last_read_at` column for unread counts | +| `006_unread_view.sql` | `user_room_unreads` view | +| `007_create_group_membership.sql` | Wallet-based group membership tracking | +| `007_secure_messages_rls.sql` | Row-Level Security policies for messages | +| `008_create_groups.sql` | Groups table with blockchain anchoring fields | +| `009_encrypted_file_references.sql` | Encrypted file reference metadata | +| `010_message_status.sql` | Message delivery status tracking | +| `011_group_tx_memo_map.sql` | Group ID → Stellar transaction memo mapping | +| `012_escrow_tables.sql` | Escrow records and escrow events audit log | -## How to apply (psql) +## How to apply -If you have direct DB access (preferred), run: +### Option A — Supabase SQL Editor + +Open your Supabase project → **SQL Editor → New query**, then paste and run each file in the order above. + +### Option B — psql ```bash -# Example using a DATABASE_URL or connection string -export DATABASE_URL="postgresql://:@:5432/" - -# Apply each file in order -psql "$DATABASE_URL" -f scripts/001_create_profiles.sql -psql "$DATABASE_URL" -f scripts/002_create_profile_trigger.sql -psql "$DATABASE_URL" -f scripts/003_create_invites.sql -psql "$DATABASE_URL" -f scripts/004_create_room_members.sql -psql "$DATABASE_URL" -f scripts/005_add_last_read_to_room_members.sql -psql "$DATABASE_URL" -f scripts/006_unread_view.sql -psql "$DATABASE_URL" -f scripts/007_create_group_membership.sql +export DATABASE_URL="postgresql://postgres:@:5432/postgres" + +for f in \ + scripts/001_create_profiles.sql \ + scripts/002_create_profile_trigger.sql \ + scripts/003_create_invites.sql \ + scripts/003_add_blockchain_fields.sql \ + scripts/003_room_members_and_removal_votes.sql \ + scripts/004_create_room_members.sql \ + scripts/005_add_last_read_to_room_members.sql \ + scripts/006_unread_view.sql \ + scripts/007_create_group_membership.sql \ + scripts/007_secure_messages_rls.sql \ + scripts/008_create_groups.sql \ + scripts/009_encrypted_file_references.sql \ + scripts/010_message_status.sql \ + scripts/011_group_tx_memo_map.sql \ + scripts/012_escrow_tables.sql; do + echo "Applying $f..." + psql "$DATABASE_URL" -f "$f" +done ``` -## How to apply (Supabase) - -If the project uses Supabase, maintainers can run the same `psql` commands against the Supabase database connection string (available from the Supabase project settings), or use the Supabase dashboard SQL editor to run each migration in order. +The Supabase connection string is in your project: **Settings → Database → Connection string**. ## Notes for maintainers -- These migrations introduce new tables, columns, and a view. Review the RLS policies in each script before applying in production. -- `scripts/005_add_last_read_to_room_members.sql` adds `last_read_at` used by the unread-count view. -- `scripts/006_unread_view.sql` creates `public.user_room_unreads` view and grants `SELECT` to `public` for convenience; adjust privileges as needed. -- `scripts/007_create_group_membership.sql` creates `public.group_membership` table for wallet-based group membership tracking. -- A development-only endpoint (`/api/rooms/seed-test`) was added that seeds a room for an authenticated user. It requires a valid Supabase session; do not enable any service-role or unauthenticated behavior in production without review. - -## Including migrations in PRs - -When creating your PR: - -- Keep SQL files in `scripts/` and name them with a numeric prefix as above. -- Add a short description in the PR body listing the migrations and any manual steps required (e.g., reindexing, backfills). -- If you expect maintainers to run migrations, include the `psql` commands or reference this file so they can apply them during merging or in CI. +- All tables have Row-Level Security (RLS) enabled. Review RLS policies in each script before applying to production. +- `005_add_last_read_to_room_members.sql` adds `last_read_at`, which is required by the unread-count view in `006`. +- `006_unread_view.sql` creates `public.user_room_unreads` and grants `SELECT` to `public` — adjust privileges as needed. +- `007_secure_messages_rls.sql` tightens message access; apply it after `007_create_group_membership.sql`. +- `012_escrow_tables.sql` creates `escrows` and `escrow_events` tables used by the full escrow lifecycle. -If you'd like, I can also: +## Adding new migrations -- Add a simple Node script that runs the migrations using an env var `DATABASE_URL`. -- Add a GitHub Actions workflow that will apply migrations to a staging database (if secrets are available). +- Name files with a numeric prefix: `013_your_migration.sql` +- Add a row to the table above in your PR +- Include the `psql` command in the PR description so maintainers can apply it during merge From eaa19817209662b2f55034a570831b44f93cc044 Mon Sep 17 00:00:00 2001 From: pugsley76 Date: Sun, 26 Apr 2026 11:25:15 -0700 Subject: [PATCH 4/4] docs: fix CI badge and clone URL to use real repo (pugsley76/AnonChat) --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c1edb89..dc14228 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,11 @@ > Speak freely. Stay anonymous. Powered by Stellar. - -[![CI](https://github.com/your-org/AnonChat/actions/workflows/ci.yml/badge.svg)](https://github.com/your-org/AnonChat/actions/workflows/ci.yml) +[![CI](https://github.com/pugsley76/AnonChat/actions/workflows/ci.yml/badge.svg)](https://github.com/pugsley76/AnonChat/actions/workflows/ci.yml) [![Vercel](https://img.shields.io/badge/deployed-Vercel-black?logo=vercel)](https://anonchat-one.vercel.app) [![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D20.9.0-brightgreen)](https://nodejs.org) -> **Badge setup:** After pushing to GitHub, replace `your-org/AnonChat` in the CI badge URL above with your actual GitHub username/org and repository name. The Vercel badge will update automatically once the project is deployed. - --- ## 🌐 Live Demo @@ -171,7 +168,7 @@ flowchart TB ### 1. Clone and install ```bash -git clone https://github.com/your-org/AnonChat.git +git clone https://github.com/pugsley76/AnonChat.git cd AnonChat npm install ```