Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,28 @@ Owns:
- Testnet-only assumptions must be explicit in docs and code comments.
- Any privileged operation must validate input shape and origin intent.

## 9a) Signal & reply authorship proof (SEP-53)

Community signals and replies now carry a Verifiable Ed25519 proof of wallet
ownership instead of a mock signature:

- **Client** (`components/signal-compose.tsx`, `app/signals/[id]/signal-detail-client.tsx`)
signs a canonical payload `{ title, body, timestamp }` via the selected
wallet's SEP-53 `signMessage` (`lib/viewer-signature.ts:signSignalPayload`).
- **Server** (`app/api/signals/route.ts`, `app/api/signals/[id]/replies/route.ts`)
reconstructs the same payload and verifies it with
`Keypair.fromPublicKey(author).verify(prefix + message, signature)`
(`lib/viewer-signature.ts:verifySignalSignature`). Missing, malformed, or
forged signatures (signature claiming another wallet) are rejected with
`400`.
- **Badge**: verified authorship is persisted as `signature_verified` on the
`signals` / `signal_replies` SQLite rows and surfaced in the UI, so verified
signals are visually distinguished from legacy posts.

All signing stays client-side; the server never holds user keys. Signed string
is a fixed-size digest of the canonical payload, keeping it under wallet
`sign_message` size limits.

## 10) Feature flags (rolling delivery)

| Flag | Env | Purpose | Default | Rollback |
Expand Down
23 changes: 22 additions & 1 deletion app/api/signals/[id]/replies/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getSignal, createReply, AttributionInReplySchema, recordReplyAttributio
import { createNotification } from "@/lib/notification-store"
import { dispatchPushNotification, extractMentionedWallets, isPhase92Enabled } from "@/lib/push-notifications"
import { createApiRequestContext } from "@/lib/api-observability"
import { verifySignalSignature } from "@/lib/viewer-signature"
import { isFeatureEnabled } from "@/lib/feature-flags"
import { z } from "zod"

Expand Down Expand Up @@ -31,6 +32,7 @@ type ReplyBody = {
body?: unknown
wallet?: unknown
signature?: unknown
timestamp?: unknown
attribution?: unknown
contributors?: unknown
}
Expand Down Expand Up @@ -81,6 +83,25 @@ export async function POST(
{ status: 400, event: "signals.reply.validation_failed", metadata: { reason: "body_length" } },
)
}
if (typeof body.timestamp !== "number" || !Number.isFinite(body.timestamp)) {
return api.json(
{ error: "Invalid signature timestamp" },
{ status: 400, event: "signals.reply.validation_failed", metadata: { reason: "timestamp" } },
)
}

const walletStr = body.wallet
const signatureVerified = await verifySignalSignature(
walletStr,
{ title: "", body: (body.body as string).trim(), timestamp: body.timestamp as number },
body.signature as string,
)
if (!signatureVerified) {
return api.json(
{ error: "Invalid signature: reply not signed by this wallet" },
{ status: 400, event: "signals.reply.invalid_signature" },
)
}

// phase-156 (Module #56): reject posts from wallets on the governed deny-list.
// No-op when the flag is off. Wrapped so a store read failure never 500s the
Expand Down Expand Up @@ -144,7 +165,6 @@ export async function POST(
return api.json({ error: "Signal not found" }, { status: 404, event: "signals.reply.signal_missing", metadata: { signal_id: id } })
}

const walletStr = body.wallet
const res = await fetch(
`${request.nextUrl.origin}/api/artist-profile?walletAddress=${encodeURIComponent(walletStr)}`,
{ headers: { "x-correlation-id": api.correlationId } },
Expand All @@ -167,6 +187,7 @@ export async function POST(
body: (body.body as string).trim(),
upvotes: [],
signature: body.signature as string,
signature_verified: signatureVerified,
})

// phase-116: record contributor attribution (flag-gated, best-effort)
Expand Down
25 changes: 24 additions & 1 deletion app/api/signals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createSignal,
getSignalChannelStats,
} from "@/lib/signal-store"
import { verifySignalSignature } from "@/lib/viewer-signature"
import type { Signal } from "@/lib/signal-store"
import { getAllWorldCollections } from "@/lib/narrative-world-store"
import { checkAndUnlock } from "@/lib/achievement-store"
Expand Down Expand Up @@ -48,6 +49,7 @@ type CreateSignalBody = {
channel?: unknown
wallet?: unknown
signature?: unknown
timestamp?: unknown
nft_token_id?: unknown
nft_collection_id?: unknown
nft_name?: unknown
Expand All @@ -72,6 +74,9 @@ export async function POST(request: NextRequest) {
if (typeof body.signature !== "string" || body.signature.length === 0) {
return NextResponse.json({ error: "Signature required" }, { status: 400 })
}
if (typeof body.timestamp !== "number" || !Number.isFinite(body.timestamp)) {
return NextResponse.json({ error: "Invalid signature timestamp" }, { status: 400 })
}
if (typeof body.title !== "string" || body.title.trim().length === 0) {
return NextResponse.json({ error: "Title required" }, { status: 400 })
}
Expand All @@ -88,6 +93,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Channel required" }, { status: 400 })
}

const walletStr = body.wallet
const proofPayload = {
title: body.title.trim(),
body: (body.body as string).trim(),
timestamp: body.timestamp as number,
}
const signatureVerified = await verifySignalSignature(
walletStr,
proofPayload,
body.signature as string,
)
if (!signatureVerified) {
return NextResponse.json(
{ error: "Invalid signature: payload not signed by this wallet" },
{ status: 400 },
)
}

let poll: Signal["poll"]
if (body.type === "poll") {
if (!isFeatureEnabled("phase-90")) {
Expand Down Expand Up @@ -131,7 +154,6 @@ export async function POST(request: NextRequest) {
}
}

const walletStr = body.wallet
const res = await fetch(
`${request.nextUrl.origin}/api/artist-profile?walletAddress=${encodeURIComponent(walletStr)}`,
).catch(() => null)
Expand All @@ -151,6 +173,7 @@ export async function POST(request: NextRequest) {
body: (body.body as string).trim(),
upvotes: [],
signature: body.signature as string,
signature_verified: signatureVerified,
type: body.type === "poll" ? "poll" : "post",
...(poll ? { poll } : {}),
...(scheduledFor ? { scheduled_for: scheduledFor } : {}),
Expand Down
8 changes: 6 additions & 2 deletions app/signals/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,13 @@ export default async function SignalDetailPage({ params }: Props) {
</span>
<span
className="font-mono text-[9px] px-1.5 py-0.5"
style={{ background: "#EEEDFE", color: "#534AB7" }}
style={
signal.signature_verified
? { background: "#E1F5EE", color: "#0F6E56" }
: { background: "#EEEDFE", color: "#534AB7" }
}
>
WALLET
{signal.signature_verified ? "✓ VERIFIED" : "✓ WALLET"}
</span>
<span className="font-mono text-[9px] text-muted-foreground/40 border border-[var(--color-border-tertiary)] px-1.5 py-0.5 uppercase tracking-widest">
{signal.channel.toUpperCase()}
Expand Down
25 changes: 20 additions & 5 deletions app/signals/[id]/signal-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from "react"
import { useWallet } from "@/components/wallet-provider"
import { useLang } from "@/components/lang-context"
import { WalletAvatar } from "@/components/wallet-avatar"
import { signSignalPayload } from "@/lib/viewer-signature"
import type { SignalReply } from "@/lib/signal-store"

const copy = {
Expand All @@ -15,6 +16,8 @@ const copy = {
ctaBusy: "[ SENDING… ]",
noWallet: "[ CONNECT_WALLET_TO_REPLY ]",
walletBadge: "✓ WALLET",
verifiedBadge: "✓ VERIFIED",
legacyBadge: "LEGACY",
},
es: {
replies: "RESPUESTAS",
Expand All @@ -24,6 +27,8 @@ const copy = {
ctaBusy: "[ ENVIANDO… ]",
noWallet: "[ CONECTAR_WALLET_PARA_RESPONDER ]",
walletBadge: "✓ WALLET",
verifiedBadge: "✓ VERIFICADO",
legacyBadge: "LEGADO",
},
}

Expand Down Expand Up @@ -60,14 +65,20 @@ export function SignalDetailClient({ signalId, initialReplies }: Props) {
setError(null)
setBusy(true)
try {
const timestamp = Date.now()
const replyBodyTrimmed = replyBody.trim()
const signature = await signSignalPayload(
{ title: "", body: replyBodyTrimmed, timestamp },
address,
)
const res = await fetch(`/api/signals/${signalId}/replies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: replyBody.trim(),
body: replyBodyTrimmed,
wallet: address,
// TODO: replace provisional signature with Freighter signMessage when available
signature: address,
signature,
timestamp,
}),
})
const data = (await res.json().catch(() => ({}))) as { reply?: SignalReply; error?: string }
Expand Down Expand Up @@ -113,9 +124,13 @@ export function SignalDetailClient({ signalId, initialReplies }: Props) {
</span>
<span
className="font-mono text-[8px] px-1 py-0.5"
style={{ background: "#EEEDFE", color: "#534AB7" }}
style={
r.signature_verified
? { background: "#E1F5EE", color: "#0F6E56" }
: { background: "#EEEDFE", color: "#534AB7" }
}
>
{t.walletBadge}
{r.signature_verified ? t.verifiedBadge : t.walletBadge}
</span>
<span className="ml-auto font-mono text-[9px] text-muted-foreground/40">
{timeAgo(r.created_at)}
Expand Down
10 changes: 8 additions & 2 deletions app/signals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const copy = {
replies: "replies",
upvotes: "upvotes",
walletBadge: "✓ WALLET",
verifiedBadge: "✓ VERIFIED",
onChain: "✓ ON-CHAIN",
unverified: "UNVERIFIED",
collector: "✓ COLLECTOR",
Expand Down Expand Up @@ -59,6 +60,7 @@ const copy = {
replies: "respuestas",
upvotes: "votos",
walletBadge: "✓ WALLET",
verifiedBadge: "✓ VERIFICADO",
onChain: "✓ ON-CHAIN",
unverified: "NO_VERIFICADO",
collector: "✓ COLECCIONISTA",
Expand Down Expand Up @@ -194,9 +196,13 @@ function PostCard({
</Link>
<span
className="font-mono text-[9px] px-1.5 py-0.5"
style={{ background: "#EEEDFE", color: "#534AB7" }}
style={
signal.signature_verified
? { background: "#E1F5EE", color: "#0F6E56" }
: { background: "#EEEDFE", color: "#534AB7" }
}
>
{t.walletBadge}
{signal.signature_verified ? t.verifiedBadge : t.walletBadge}
</span>
{authorProfile.isCollector && (
<span
Expand Down
21 changes: 21 additions & 0 deletions app/tactical-command.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@
}
}

@keyframes animated-noise-drift {
0% {
background-position: 0 0;
}
25% {
background-position: -1px 1px;
}
50% {
background-position: 1px -1px;
}
75% {
background-position: -1px -1px;
}
100% {
background-position: 0 0;
}
}

.tactical-crt-veil {
pointer-events: none;
position: absolute;
Expand Down Expand Up @@ -1260,6 +1278,9 @@
}

@media (prefers-reduced-motion: reduce) {
.tactical-film-grain {
animation: none;
}
.forge-field-ring--active {
animation: none;
}
Expand Down
Loading