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.
This commit is contained in:
jwinterm
2026-10-08 18:27:05 -04:00
parent 3b8f03eb9c
commit c26a31160f
4 changed files with 180 additions and 22 deletions
+12
View File
@@ -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' && const isClaimable = tip?.status === 'pending' &&
!isLegacyTip &&
tip.is_public && tip.is_public &&
fragmentKey && fragmentKey &&
(tip.funding_confirmations ?? 0) >= (tip.confirmations_required ?? 0); (tip.funding_confirmations ?? 0) >= (tip.confirmations_required ?? 0);
@@ -255,6 +260,13 @@ export default function TipPageClient({ tipId }: { tipId: string }) {
)} )}
{/* Claim button for public tips */} {/* Claim button for public tips */}
{isLegacyTip && tip.status === 'pending' && (
<div className="rounded-lg border border-[#fbeb0a]/30 bg-[#fbeb0a]/5 p-4 text-sm text-gray-300">
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.
</div>
)}
{isClaimable && !claimSuccess && ( {isClaimable && !claimSuccess && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
+11 -22
View File
@@ -1,4 +1,7 @@
import { parseUsernameResponse } from './username'; import { parseUsernameResponse } from './username';
import { lookupPublicTip, type PublicTipInfo } from './public-tip';
export type { PublicTipInfo } from './public-tip';
// API client for Smirk backend // 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 // Public counters (user count, stats) may live on a different backend than
// sign-in. Unset means the same backend. // sign-in. Unset means the same backend.
const STATS_API_URL = process.env.NEXT_PUBLIC_STATS_API_URL || API_URL; 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 { export interface ChallengeResponse {
challenge: string; challenge: string;
@@ -189,29 +195,12 @@ export async function unlinkSocial(token: string, platform: string): Promise<voi
// Public Tips // Public Tips
// ============================================================================= // =============================================================================
export interface PublicTipInfo {
id: string;
asset: string;
amount: number;
status: string;
created_at: string;
is_public: boolean;
funding_confirmations: number;
confirmations_required: number;
}
export async function getPublicTipInfo(tipId: string): Promise<PublicTipInfo> { export async function getPublicTipInfo(tipId: string): Promise<PublicTipInfo> {
const res = await fetch(`${API_URL}/api/v1/tips/social/${tipId}/public`); return lookupPublicTip(tipId, {
apiUrl: API_URL,
if (!res.ok) { legacyApiUrl: LEGACY_API_URL,
if (res.status === 404) { fetchImpl: (url) => fetch(url),
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();
} }
// ============================================================================= // =============================================================================
+79
View File
@@ -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<string, unknown> = {}) {
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<string, { status: number; body?: unknown }>) {
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<string, unknown>;
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`);
});
+78
View File
@@ -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<unknown>;
}>;
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<string, unknown>;
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<unknown> }): Promise<string> {
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<PublicTipInfo> {
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));
}