diff --git a/src/app/tip/[id]/TipPageClient.tsx b/src/app/tip/[id]/TipPageClient.tsx index 06f3343..6240a20 100644 --- a/src/app/tip/[id]/TipPageClient.tsx +++ b/src/app/tip/[id]/TipPageClient.tsx @@ -119,7 +119,12 @@ export default function TipPageClient({ tipId }: { tipId: string }) { } }; + // Tips made by v0.2.x wallets live on the legacy backend; a v0.3 wallet cannot + // claim them yet, so the page shows them without a claim button. + const isLegacyTip = tip?.source === 'legacy'; + const isClaimable = tip?.status === 'pending' && + !isLegacyTip && tip.is_public && fragmentKey && (tip.funding_confirmations ?? 0) >= (tip.confirmations_required ?? 0); @@ -255,6 +260,13 @@ export default function TipPageClient({ tipId }: { tipId: string }) { )} {/* Claim button for public tips */} + {isLegacyTip && tip.status === 'pending' && ( +
+ This tip was made with an earlier version of Smirk. Claiming it from this page + needs the next Smirk Wallet update. The tip stays safe until then; keep this link. +
+ )} + {isClaimable && !claimSuccess && (

diff --git a/src/lib/api.ts b/src/lib/api.ts index 7d25f7a..8e55b08 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,7 @@ import { parseUsernameResponse } from './username'; +import { lookupPublicTip, type PublicTipInfo } from './public-tip'; + +export type { PublicTipInfo } from './public-tip'; // API client for Smirk backend @@ -6,6 +9,9 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; // Public counters (user count, stats) may live on a different backend than // sign-in. Unset means the same backend. const STATS_API_URL = process.env.NEXT_PUBLIC_STATS_API_URL || API_URL; +// Tips created by v0.2.x wallets exist only on the legacy backend. The tip page +// falls back to it when core has no such tip. +const LEGACY_API_URL = process.env.NEXT_PUBLIC_LEGACY_API_URL || 'https://backend.smirk.cash'; export interface ChallengeResponse { challenge: string; @@ -189,29 +195,12 @@ export async function unlinkSocial(token: string, platform: string): Promise { - const res = await fetch(`${API_URL}/api/v1/tips/social/${tipId}/public`); - - if (!res.ok) { - if (res.status === 404) { - throw new Error('Tip not found or not available'); - } - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Failed to get tip info'); - } - - return res.json(); + return lookupPublicTip(tipId, { + apiUrl: API_URL, + legacyApiUrl: LEGACY_API_URL, + fetchImpl: (url) => fetch(url), + }); } // ============================================================================= diff --git a/src/lib/public-tip.test.ts b/src/lib/public-tip.test.ts new file mode 100644 index 0000000..d03194d --- /dev/null +++ b/src/lib/public-tip.test.ts @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { lookupPublicTip, normalizePublicTip, TipNotFoundError } from './public-tip.ts'; + +const CORE = 'https://api.example'; +const LEGACY = 'https://legacy.example'; + +function tipBody(extra: Record = {}) { + return { + id: 'tip-1', + asset: 'wow', + amount: 5, + status: 'pending', + created_at: '2026-08-19T10:19:00Z', + is_public: true, + funding_confirmations: 4, + confirmations_required: 4, + ...extra, + }; +} + +function fakeFetch(responses: Record) { + const calls: string[] = []; + const impl = async (url: string) => { + calls.push(url); + const host = Object.keys(responses).find((h) => url.startsWith(h)); + const r = host ? responses[host] : { status: 500 }; + return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => r.body ?? {} }; + }; + return { impl, calls }; +} + +test('a core tip is returned from core without touching legacy', async () => { + const f = fakeFetch({ [CORE]: { status: 200, body: tipBody() } }); + const tip = await lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }); + assert.equal(tip.source, 'core'); + assert.equal(f.calls.length, 1); +}); + +test('a v0.2.x tip missing from core is found on legacy', async () => { + const f = fakeFetch({ [CORE]: { status: 404 }, [LEGACY]: { status: 200, body: tipBody() } }); + const tip = await lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }); + assert.equal(tip.source, 'legacy'); + assert.equal(tip.amount, 5); + assert.deepEqual(f.calls, [`${CORE}/api/v1/tips/social/tip-1/public`, `${LEGACY}/api/v1/tips/social/tip-1/public`]); +}); + +test('a tip on neither backend is not found', async () => { + const f = fakeFetch({ [CORE]: { status: 404 }, [LEGACY]: { status: 404 } }); + await assert.rejects( + lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }), + TipNotFoundError, + ); +}); + +test('a core server error is reported and does not fall back', async () => { + const f = fakeFetch({ [CORE]: { status: 503, body: { error: 'busy' } }, [LEGACY]: { status: 200, body: tipBody() } }); + await assert.rejects(lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }), /busy/); + assert.equal(f.calls.length, 1); +}); + +test('without a distinct legacy URL a core 404 is final', async () => { + const f = fakeFetch({ [CORE]: { status: 404 } }); + await assert.rejects(lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: CORE, fetchImpl: f.impl }), TipNotFoundError); + assert.equal(f.calls.length, 1); +}); + +test('legacy key material is never carried into the page model', () => { + const tip = normalizePublicTip(tipBody({ encrypted_key: 'secret', tip_address: 'Wo...' }), 'legacy') as unknown as Record; + assert.equal('encrypted_key' in tip, false); + assert.equal('tip_address' in tip, false); +}); + +test('the tip id is URL-encoded', async () => { + const f = fakeFetch({ [CORE]: { status: 200, body: tipBody() } }); + await lookupPublicTip('a/b', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }); + assert.equal(f.calls[0], `${CORE}/api/v1/tips/social/a%2Fb/public`); +}); diff --git a/src/lib/public-tip.ts b/src/lib/public-tip.ts new file mode 100644 index 0000000..1687f82 --- /dev/null +++ b/src/lib/public-tip.ts @@ -0,0 +1,78 @@ +// Public tip lookup across the two backends. +// +// Tips created by v0.3 wallets live on the core backend (api.smirk.cash). Tips +// created by v0.2.x wallets live only on the legacy backend (backend.smirk.cash). +// The tip page must show both, so a 404 from core falls back to legacy. + +export type TipSource = 'core' | 'legacy'; + +export interface PublicTipInfo { + id: string; + asset: string; + amount: number; + status: string; + created_at: string; + is_public: boolean; + funding_confirmations: number; + confirmations_required: number; + /** Which backend holds the tip. Only core tips can be claimed by a v0.3 wallet. */ + source: TipSource; +} + +export class TipNotFoundError extends Error { + constructor() { + super('Tip not found or not available'); + this.name = 'TipNotFoundError'; + } +} + +type FetchLike = (url: string) => Promise<{ + ok: boolean; + status: number; + json(): Promise; +}>; + +function toNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +/** Keep only the fields the page renders; never carry key material along. */ +export function normalizePublicTip(raw: unknown, source: TipSource): PublicTipInfo { + const r = (raw && typeof raw === 'object' ? raw : {}) as Record; + if (typeof r.id !== 'string' || typeof r.asset !== 'string' || typeof r.status !== 'string') { + throw new Error('Failed to get tip info'); + } + return { + id: r.id, + asset: r.asset, + amount: toNumber(r.amount), + status: r.status, + created_at: typeof r.created_at === 'string' ? r.created_at : '', + is_public: r.is_public === true, + funding_confirmations: toNumber(r.funding_confirmations), + confirmations_required: toNumber(r.confirmations_required), + source, + }; +} + +async function errorMessage(res: { json(): Promise }): Promise { + const data = (await res.json().catch(() => ({}))) as { error?: unknown }; + return typeof data.error === 'string' && data.error ? data.error : 'Failed to get tip info'; +} + +export async function lookupPublicTip( + tipId: string, + opts: { apiUrl: string; legacyApiUrl?: string; fetchImpl: FetchLike }, +): Promise { + const path = `/api/v1/tips/social/${encodeURIComponent(tipId)}/public`; + const core = await opts.fetchImpl(`${opts.apiUrl}${path}`); + if (core.ok) return normalizePublicTip(await core.json(), 'core'); + if (core.status !== 404) throw new Error(await errorMessage(core)); + + const legacyUrl = opts.legacyApiUrl; + if (!legacyUrl || legacyUrl === opts.apiUrl) throw new TipNotFoundError(); + const legacy = await opts.fetchImpl(`${legacyUrl}${path}`); + if (legacy.ok) return normalizePublicTip(await legacy.json(), 'legacy'); + if (legacy.status === 404) throw new TipNotFoundError(); + throw new Error(await errorMessage(legacy)); +}