/** * Signed-request helper. Must match backend/app/services/signed_request.py exactly. * * Message format: * TrumpSignal · {ACTION} * wallet: {wallet_lowercase} * timestamp: {ms} * body: {sha256_hex or "-"} */ async function sha256Hex(text: string): Promise { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, '0')) .join('') } /** Canonical JSON: sorted keys, no whitespace. Must match Python's json.dumps(..., sort_keys, separators). */ function canonicalJson(v: unknown): string { if (v === null || typeof v !== 'object') return JSON.stringify(v) if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']' const keys = Object.keys(v as object).sort() return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson((v as Record)[k])).join(',') + '}' } export async function bodyHash(body: unknown | null): Promise { if (body === null || body === undefined) return '-' return sha256Hex(canonicalJson(body)) } export async function buildSignMessage( action: string, wallet: string, timestampMs: number, body: unknown | null, ): Promise { const h = await bodyHash(body) return `TrumpSignal · ${action}\nwallet: ${wallet.toLowerCase()}\ntimestamp: ${timestampMs}\nbody: ${h}` } export interface SignedEnvelope { wallet: string timestamp: number signature: string } /** * Produce {timestamp, signature} for a given action + optional body. * Caller supplies wagmi's `signMessageAsync`. */ /** * Get-or-create a cached "view" envelope for a wallet. Reused across page loads * within a 4-minute window (server accepts 5-min skew). Backend's replay-guard * is disabled for the view action so the same sig can be used multiple times. */ const VIEW_TTL_MS = 20 * 60 * 1000 // 20 min — backend's replay-guard is disabled for view actions /** * Read-only: returns a cached, not-yet-expired view envelope if one exists. * Never signs — never triggers a wallet popup. Returns null if no fresh cache. */ export function getCachedViewEnvelope(action: string, wallet: string): SignedEnvelope | null { if (typeof window === 'undefined') return null const cacheKey = `ts:view:${action}:${wallet.toLowerCase()}` const raw = sessionStorage.getItem(cacheKey) if (!raw) return null try { const env = JSON.parse(raw) as SignedEnvelope if (Date.now() - env.timestamp < VIEW_TTL_MS) return env } catch (err) { console.warn('[signedRequest] corrupt cached envelope, ignoring', err) sessionStorage.removeItem(cacheKey) } return null } export async function getOrCreateViewEnvelope(params: { action: string wallet: string signMessageAsync: (args: { message: string }) => Promise }): Promise { if (typeof window === 'undefined') throw new Error('view envelope requires browser') const wallet = params.wallet.toLowerCase() const cacheKey = `ts:view:${params.action}:${wallet}` const raw = sessionStorage.getItem(cacheKey) if (raw) { try { const env = JSON.parse(raw) as SignedEnvelope if (Date.now() - env.timestamp < VIEW_TTL_MS) return env } catch (err) { console.warn('[signedRequest] corrupt cached envelope, ignoring', err) sessionStorage.removeItem(cacheKey) } } const env = await signRequest({ action: params.action, wallet, body: null, signMessageAsync: params.signMessageAsync, }) sessionStorage.setItem(cacheKey, JSON.stringify(env)) return env } export async function signRequest(params: { action: string wallet: string body: unknown | null signMessageAsync: (args: { message: string }) => Promise }): Promise { const timestamp = Date.now() const message = await buildSignMessage(params.action, params.wallet, timestamp, params.body) const signature = await params.signMessageAsync({ message }) return { wallet: params.wallet.toLowerCase(), timestamp, signature } }