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));
+}