diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md
index e04c0a0..61abcd1 100644
--- a/PROJECT_ARCHITECTURE.md
+++ b/PROJECT_ARCHITECTURE.md
@@ -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 |
diff --git a/app/api/signals/[id]/replies/route.ts b/app/api/signals/[id]/replies/route.ts
index bf45704..20539fa 100644
--- a/app/api/signals/[id]/replies/route.ts
+++ b/app/api/signals/[id]/replies/route.ts
@@ -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"
@@ -31,6 +32,7 @@ type ReplyBody = {
body?: unknown
wallet?: unknown
signature?: unknown
+ timestamp?: unknown
attribution?: unknown
contributors?: unknown
}
@@ -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
@@ -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 } },
@@ -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)
diff --git a/app/api/signals/route.ts b/app/api/signals/route.ts
index d178990..9cc3df7 100644
--- a/app/api/signals/route.ts
+++ b/app/api/signals/route.ts
@@ -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"
@@ -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
@@ -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 })
}
@@ -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")) {
@@ -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)
@@ -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 } : {}),
diff --git a/app/signals/[id]/page.tsx b/app/signals/[id]/page.tsx
index bf72acf..9fe8d61 100644
--- a/app/signals/[id]/page.tsx
+++ b/app/signals/[id]/page.tsx
@@ -105,9 +105,13 @@ export default async function SignalDetailPage({ params }: Props) {
- ✓ WALLET
+ {signal.signature_verified ? "✓ VERIFIED" : "✓ WALLET"}
{signal.channel.toUpperCase()}
diff --git a/app/signals/[id]/signal-detail-client.tsx b/app/signals/[id]/signal-detail-client.tsx
index 631d97e..4bde7c4 100644
--- a/app/signals/[id]/signal-detail-client.tsx
+++ b/app/signals/[id]/signal-detail-client.tsx
@@ -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 = {
@@ -15,6 +16,8 @@ const copy = {
ctaBusy: "[ SENDING… ]",
noWallet: "[ CONNECT_WALLET_TO_REPLY ]",
walletBadge: "✓ WALLET",
+ verifiedBadge: "✓ VERIFIED",
+ legacyBadge: "LEGACY",
},
es: {
replies: "RESPUESTAS",
@@ -24,6 +27,8 @@ const copy = {
ctaBusy: "[ ENVIANDO… ]",
noWallet: "[ CONECTAR_WALLET_PARA_RESPONDER ]",
walletBadge: "✓ WALLET",
+ verifiedBadge: "✓ VERIFICADO",
+ legacyBadge: "LEGADO",
},
}
@@ -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 }
@@ -113,9 +124,13 @@ export function SignalDetailClient({ signalId, initialReplies }: Props) {
- {t.walletBadge}
+ {r.signature_verified ? t.verifiedBadge : t.walletBadge}
{timeAgo(r.created_at)}
diff --git a/app/signals/page.tsx b/app/signals/page.tsx
index 9d6253a..2589424 100644
--- a/app/signals/page.tsx
+++ b/app/signals/page.tsx
@@ -31,6 +31,7 @@ const copy = {
replies: "replies",
upvotes: "upvotes",
walletBadge: "✓ WALLET",
+ verifiedBadge: "✓ VERIFIED",
onChain: "✓ ON-CHAIN",
unverified: "UNVERIFIED",
collector: "✓ COLLECTOR",
@@ -59,6 +60,7 @@ const copy = {
replies: "respuestas",
upvotes: "votos",
walletBadge: "✓ WALLET",
+ verifiedBadge: "✓ VERIFICADO",
onChain: "✓ ON-CHAIN",
unverified: "NO_VERIFICADO",
collector: "✓ COLECCIONISTA",
@@ -194,9 +196,13 @@ function PostCard({
- {t.walletBadge}
+ {signal.signature_verified ? t.verifiedBadge : t.walletBadge}
{authorProfile.isCollector && (
(null)
+ const containerRef = useRef(null)
+ const [isVisible, setIsVisible] = useState(true)
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
useEffect(() => {
- const canvas = canvasRef.current
- if (!canvas) return
-
- const ctx = canvas.getContext("2d")
- if (!ctx) return
-
- let animationId: number
- let frame = 0
-
- const resize = () => {
- canvas.width = canvas.offsetWidth / 2
- canvas.height = canvas.offsetHeight / 2
- }
-
- const generateNoise = () => {
- const imageData = ctx.createImageData(canvas.width, canvas.height)
- const data = imageData.data
-
- for (let i = 0; i < data.length; i += 4) {
- const value = Math.random() * 255
- data[i] = value // R
- data[i + 1] = value // G
- data[i + 2] = value // B
- data[i + 3] = 255 // A
- }
+ const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
+ setPrefersReducedMotion(mql.matches)
- ctx.putImageData(imageData, 0, 0)
- }
-
- const animate = () => {
- frame++
- // Update noise every 2 frames for performance while still looking animated
- if (frame % 2 === 0) {
- generateNoise()
- }
- animationId = requestAnimationFrame(animate)
- }
-
- resize()
- window.addEventListener("resize", resize)
- animate()
+ const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches)
+ mql.addEventListener("change", handler)
+ return () => mql.removeEventListener("change", handler)
+ }, [])
- return () => {
- window.removeEventListener("resize", resize)
- cancelAnimationFrame(animationId)
- }
+ useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+
+ const observer = new IntersectionObserver(
+ ([entry]) => setIsVisible(entry.isIntersecting),
+ { threshold: 0 },
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
}, [])
+ const shouldAnimate = isVisible && !prefersReducedMotion
+
return (
-
)
diff --git a/components/signal-compose.tsx b/components/signal-compose.tsx
index 6d7ae9b..46b8976 100644
--- a/components/signal-compose.tsx
+++ b/components/signal-compose.tsx
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/dialog"
import { useWallet } from "@/components/wallet-provider"
import { useLang } from "@/components/lang-context"
+import { signSignalPayload } from "@/lib/viewer-signature"
import type { Signal } from "@/lib/signal-store"
type ChannelOption = { id: string; label: string; count: number }
@@ -127,14 +128,18 @@ export function SignalCompose({ open, onOpenChange, channels, onCreated }: Props
setError(null)
setBusy(true)
try {
- // TODO: replace provisional signature with Freighter signMessage when available
- const signature = address
+ const timestamp = Date.now()
+ const signature = await signSignalPayload(
+ { title: titleVal.trim(), body: bodyVal.trim(), timestamp },
+ address,
+ )
const body: Record = {
title: titleVal,
body: bodyVal,
channel,
wallet: address,
signature,
+ timestamp,
type: signalType,
}
if (signalType === "poll") body.poll_options = pollOptions
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index c8f8683..95b6f3c 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -127,6 +127,12 @@ All handlers live in `app/api/**/route.ts`.
- `forge-agent` and `claim-bounty` now use strict TypeScript response unions.
- Error payloads are explicit and status-code aligned.
- No untyped `any` responses should be used for public API contracts.
+- `POST /api/signals` and `POST /api/signals/[id]/replies` now require a real
+ SEP-53 Ed25519 `signature` over `{ title, body, timestamp }` (signals) or
+ `{ title: "", body, timestamp }` (replies), plus a numeric `timestamp`. The
+ server verifies ownership with `Keypair.fromPublicKey(wallet).verify(...)`
+ and returns `400` for missing/invalid/forged signatures. Verified authorship
+ is persisted as `signature_verified` and shown as a verified badge in the UI.
### 5.3 Flag-gated API extensions
diff --git a/lib/__tests__/viewer-signature.test.ts b/lib/__tests__/viewer-signature.test.ts
new file mode 100644
index 0000000..97ba764
--- /dev/null
+++ b/lib/__tests__/viewer-signature.test.ts
@@ -0,0 +1,73 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+import { Keypair } from "@stellar/stellar-sdk"
+import {
+ SIGNATURE_PREFIX,
+ canonicalSignalPayload,
+ signalProofMessage,
+ verifySignalSignature,
+} from "@/lib/viewer-signature"
+
+/**
+ * Simulates the client signing step (SEP-53 framing) without a real wallet.
+ * A SEP-53 signature is `ed25519_sign(sha256(prefix + message))`, which is
+ * exactly what `Keypair.sign(prefix + message)` produces in @stellar/stellar-sdk.
+ */
+async function signPayload(payload: {
+ title: string
+ body: string
+ timestamp: number
+}, keypair: Keypair): Promise {
+ const message = await signalProofMessage(payload)
+ const data = SIGNATURE_PREFIX + message
+ return Buffer.from(keypair.sign(data)).toString("base64")
+}
+
+test("verifySignalSignature accepts a genuine wallet signature", async () => {
+ const kp = Keypair.random()
+ const payload = { title: "Hello", body: "World", timestamp: 1234567890 }
+ const signature = await signPayload(payload, kp)
+ const ok = await verifySignalSignature(kp.publicKey(), payload, signature)
+ assert.equal(ok, true)
+})
+
+test("verifySignalSignature rejects a forged signature from another wallet", async () => {
+ const author = Keypair.random()
+ const forger = Keypair.random()
+ const payload = { title: "Hello", body: "World", timestamp: 1234567890 }
+ // Signed with the forger's key, but claimed to be authored by `author`.
+ const signature = await signPayload(payload, forger)
+ const ok = await verifySignalSignature(author.publicKey(), payload, signature)
+ assert.equal(ok, false)
+})
+
+test("verifySignalSignature rejects tampered content", async () => {
+ const kp = Keypair.random()
+ const payload = { title: "Hello", body: "World", timestamp: 1234567890 }
+ const signature = await signPayload(payload, kp)
+ // Body changed after signing — signature must no longer verify.
+ const tampered = await verifySignalSignature(kp.publicKey(), { ...payload, body: "Tampered" }, signature)
+ assert.equal(tampered, false)
+})
+
+test("verifySignalSignature rejects legacy mock signatures (wallet address as signature)", async () => {
+ const kp = Keypair.random()
+ const payload = { title: "Hello", body: "World", timestamp: 1234567890 }
+ // Legacy posts stored `signature` = the address itself.
+ const ok = await verifySignalSignature(kp.publicKey(), payload, kp.publicKey())
+ assert.equal(ok, false)
+})
+
+test("verifySignalSignature returns false for garbage input", async () => {
+ const kp = Keypair.random()
+ const payload = { title: "Hello", body: "World", timestamp: 1234567890 }
+ const ok = await verifySignalSignature(kp.publicKey(), payload, "!!not-base64-signature!!")
+ assert.equal(ok, false)
+})
+
+test("canonicalSignalPayload is deterministic and stable", () => {
+ const a = canonicalSignalPayload({ title: "T", body: "B", timestamp: 42 })
+ const b = canonicalSignalPayload({ title: "T", body: "B", timestamp: 42 })
+ assert.equal(a, b)
+ assert.ok(a.includes("42"))
+})
diff --git a/lib/signal-store.ts b/lib/signal-store.ts
index a3d114a..62fbba7 100644
--- a/lib/signal-store.ts
+++ b/lib/signal-store.ts
@@ -38,6 +38,7 @@ export type Signal = {
upvotes: string[];
created_at: number;
signature: string;
+ signature_verified?: boolean;
type?: "post" | "poll";
poll?: SignalPoll;
scheduled_for?: number;
@@ -57,6 +58,7 @@ export type SignalReply = {
upvotes: string[];
created_at: number;
signature: string;
+ signature_verified?: boolean;
media?: MediaAttachment[];
};
@@ -78,6 +80,7 @@ type SignalRow = {
upvotes_json: string;
created_at: number;
signature: string;
+ signature_verified: number | null;
type: string | null;
poll_json: string | null;
scheduled_for: number | null;
@@ -97,6 +100,7 @@ type ReplyRow = {
upvotes_json: string;
created_at: number;
signature: string;
+ signature_verified: number | null;
media_json: string | null;
};
@@ -115,6 +119,7 @@ function rowToSignal(row: SignalRow): Signal {
upvotes: JSON.parse(row.upvotes_json) as string[],
created_at: row.created_at,
signature: row.signature,
+ signature_verified: row.signature_verified === 1,
type: (row.type as Signal["type"]) ?? undefined,
poll: row.poll_json
? (JSON.parse(row.poll_json) as SignalPoll)
@@ -140,6 +145,7 @@ function rowToReply(row: ReplyRow): SignalReply {
upvotes: JSON.parse(row.upvotes_json) as string[],
created_at: row.created_at,
signature: row.signature,
+ signature_verified: row.signature_verified === 1,
media: row.media_json
? (JSON.parse(row.media_json) as MediaAttachment[])
: undefined,
@@ -218,10 +224,11 @@ export async function createSignal(
`INSERT INTO signals
(id, author_wallet, author_display, channel, title, body,
nft_token_id, nft_collection_id, nft_name, nft_image,
- upvotes_json, upvote_count, created_at, signature, type,
+ upvotes_json, upvote_count, created_at, signature,
+ signature_verified, type,
poll_json, scheduled_for, status, taken_down, takedown_reason,
taken_down_at, media_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
signal.id,
@@ -238,6 +245,7 @@ export async function createSignal(
(signal.upvotes ?? []).length,
signal.created_at,
signal.signature,
+ signal.signature_verified ? 1 : 0,
signal.type ?? null,
signal.poll ? JSON.stringify(signal.poll) : null,
signal.scheduled_for ?? null,
@@ -347,8 +355,8 @@ export async function createReply(
.prepare(
`INSERT INTO signal_replies
(id, signal_id, author_wallet, author_display, body,
- upvotes_json, created_at, signature, media_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ upvotes_json, created_at, signature, signature_verified, media_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
reply.id,
@@ -359,6 +367,7 @@ export async function createReply(
JSON.stringify(reply.upvotes ?? []),
reply.created_at,
reply.signature,
+ reply.signature_verified ? 1 : 0,
reply.media ? JSON.stringify(reply.media) : null,
);
return reply;
diff --git a/lib/sqlite-db.ts b/lib/sqlite-db.ts
index b021641..e2bf656 100644
--- a/lib/sqlite-db.ts
+++ b/lib/sqlite-db.ts
@@ -70,6 +70,7 @@ CREATE TABLE IF NOT EXISTS signals (
upvote_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
signature TEXT NOT NULL,
+ signature_verified INTEGER NOT NULL DEFAULT 0,
type TEXT,
poll_json TEXT,
scheduled_for INTEGER,
@@ -97,6 +98,7 @@ CREATE TABLE IF NOT EXISTS signal_replies (
upvotes_json TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
signature TEXT NOT NULL,
+ signature_verified INTEGER NOT NULL DEFAULT 0,
media_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_replies_signal_created
@@ -188,7 +190,21 @@ export function getDb(): DatabaseSync {
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(SCHEMA);
- ensureListingColumns(db);
+ // Idempotent migration for databases created before signature_verified existed.
+ try {
+ db.exec(
+ "ALTER TABLE signals ADD COLUMN signature_verified INTEGER NOT NULL DEFAULT 0;",
+ );
+ } catch {
+ // Column already present — no-op.
+ }
+ try {
+ db.exec(
+ "ALTER TABLE signal_replies ADD COLUMN signature_verified INTEGER NOT NULL DEFAULT 0;",
+ );
+ } catch {
+ // Column already present — no-op.
+ }
return db;
}
diff --git a/lib/viewer-signature.ts b/lib/viewer-signature.ts
index 33ce48e..4e70110 100644
--- a/lib/viewer-signature.ts
+++ b/lib/viewer-signature.ts
@@ -1,10 +1,86 @@
-/** Firma corta derivada del visor (wallet u otro id) — cambia por sesión/dirección; no es criptografía fuerte. */
-export function viewerSignatureShort(address: string | null | undefined): string {
- const s = (address ?? "ANON_VIEWER").trim()
- let h = 2166136261
- for (let i = 0; i < s.length; i++) {
- h ^= s.charCodeAt(i)
- h = Math.imul(h, 16777619) >>> 0
+/**
+ * Community-signal proof of wallet ownership (SEP-53 Ed25519 message signing).
+ *
+ * The previous implementation stored a mock signature (the wallet address
+ * itself), which proves nothing. Here the client signs a canonical payload and
+ * the server verifies it with @stellar/stellar-sdk `Keypair.verify` before the
+ * signal is persisted, so a forger cannot claim authorship of another wallet.
+ *
+ * Signing and verification share a single deterministic message derivation so
+ * the bytes signed client-side are exactly the bytes verified server-side.
+ */
+
+/** SEP-53 fixed prefix, concatenated with the message before hashing. */
+export const SIGNATURE_PREFIX = "Stellar Signed Message:\n"
+
+/** Canonical payload signed/proven for a community signal. */
+export type SignalProofPayload = {
+ title: string
+ body: string
+ timestamp: number
+}
+
+/**
+ * Deterministic JSON serialization of the payload. Both the client (before
+ * signing) and the server (before verifying) build this identical string, so
+ * the Ed25519 signature always binds to the exact signal content.
+ */
+export function canonicalSignalPayload(payload: SignalProofPayload): string {
+ return JSON.stringify({ title: payload.title, body: payload.body, timestamp: payload.timestamp })
+}
+
+/** SHA-256 of a UTF-8 string, hex-encoded. Works on both browser & Node 22+. */
+export async function sha256Hex(text: string): Promise {
+ const data = new TextEncoder().encode(text)
+ const buf = await crypto.subtle.digest("SHA-256", data)
+ return Array.from(new Uint8Array(buf))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("")
+}
+
+/**
+ * Fixed-size message that gets signed. We sign a digest of the canonical
+ * payload rather than the raw payload so the signed string stays well under
+ * the ~1KB limit some wallets impose on `sign_message`.
+ */
+export async function signalProofMessage(payload: SignalProofPayload): Promise {
+ const digest = await sha256Hex(canonicalSignalPayload(payload))
+ return `phase-signal:v1:${digest}`
+}
+
+/** Server-only: verify a SEP-53 Ed25519 signature over the given payload. */
+export async function verifySignalSignature(
+ wallet: string,
+ payload: SignalProofPayload,
+ signatureBase64: string,
+): Promise {
+ try {
+ const { Keypair } = await import("@stellar/stellar-sdk")
+ const message = await signalProofMessage(payload)
+ const data = new TextEncoder().encode(SIGNATURE_PREFIX + message)
+ const signature = Buffer.from(signatureBase64, "base64")
+ return Keypair.fromPublicKey(wallet).verify(data, signature)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Client-only: sign the payload with the currently-selected wallet via the
+ * Stellar Wallets Kit `signMessage` (SEP-53). Returns the base64 signature.
+ */
+export async function signSignalPayload(
+ payload: SignalProofPayload,
+ address: string,
+): Promise {
+ const { StellarWalletsKit } = await import("@creit.tech/stellar-wallets-kit")
+ const message = await signalProofMessage(payload)
+ const result = (await StellarWalletsKit.signMessage(message, { address })) as {
+ signedMessage?: string | null
+ }
+ const signature = result?.signedMessage
+ if (!signature) {
+ throw new Error("Wallet signing returned no signature")
}
- return (h >>> 0).toString(16).toUpperCase().padStart(8, "0").slice(0, 6)
+ return signature
}