From c26a31160f9dcf5e06066614902ac64a1e187eeb Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 8 Oct 2026 18:27:05 -0400 Subject: [PATCH] fix(tips): show tips made by v0.2.x wallets on the tip page The site now reads tips from the v0.3 backend, which does not hold tips created by v0.2.x wallets, so their links said "Tip not found". A 404 from the v0.3 backend now falls back to the legacy backend. Such tips are shown without a claim button, with a note that claiming needs the next wallet update, because a v0.3 wallet cannot claim them yet. Only the fields the page renders are kept from either response. --- src/app/tip/[id]/TipPageClient.tsx | 12 +++++ src/lib/api.ts | 33 +++++-------- src/lib/public-tip.test.ts | 79 ++++++++++++++++++++++++++++++ src/lib/public-tip.ts | 78 +++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 src/lib/public-tip.test.ts create mode 100644 src/lib/public-tip.ts 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)); +}