feat: single-asset auth, UI improvements

- Auth flow now uses single signature from user's chosen coin
- Update SmirkSignResult types to match extension API
- Add Creepster font for branding
- Better error messages from API responses
- Update docs to reflect single-asset auth is working
This commit is contained in:
jwinterm
2026-01-25 12:40:57 -05:00
parent 9ccc962514
commit e7b459f5bd
7 changed files with 58 additions and 39 deletions
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.local.example
# vercel
.vercel
+4 -6
View File
@@ -52,14 +52,12 @@ The Smirk extension injects a `window.smirk` API object into web pages. The webs
5. Website calls `POST /api/v1/auth/website/challenge` with origin
6. Backend returns challenge message (with nonce + timestamp + origin)
7. Website calls `window.smirk.signMessage(challenge)`
8. Extension shows signing popup with the message
9. User approves → Extension returns signatures from all 5 keys
10. Website sends `{ challenge_id, signatures }` to `POST /api/v1/auth/website/verify`
11. Backend verifies signatures, looks up user by BTC pubkey_hash, issues JWT session
8. Extension shows asset picker - user chooses favorite coin
9. User signs → Extension returns signature from chosen asset
10. Website sends `{ challenge_id, signature }` to `POST /api/v1/auth/website/verify`
11. Backend verifies signature, looks up user by pubkey_hash, issues JWT session
12. Website stores JWT, user is now authenticated
**Future**: Allow single-asset auth (user picks their favorite coin to sign with).
### Tech Stack
- **Framework**: Next.js 14+ (App Router)
+6 -9
View File
@@ -134,14 +134,11 @@ Origin: https://smirk.cash
Challenge should expire after ~5 minutes to prevent replay attacks.
### Single-Asset Authentication (Future Enhancement)
### Single-Asset Authentication (DONE - 2026-01-25)
**Current implementation**: Backend requires signatures from all 5 asset keys.
User picks their favorite coin to sign with:
- Simpler UX - choose one coin, sign once
- User data - "What's your favorite coin?" insight
- Any of the 5 coins proves wallet ownership
**Better approach**: Let users pick their favorite coin to sign with. Benefits:
1. **Simpler UX** - User chooses one coin, signs once
2. **User data** - "What's your favorite coin?" gives us insight into our user base
3. **Proof of concept** - Demonstrates wallet-based auth for ANY of these 5 coins
4. **Still secure** - We already know all 5 pubkeys; signing with ANY one proves wallet ownership
This positions Smirk as infrastructure for wallet-based authentication across multiple cryptocurrencies. Other websites could use the same pattern - "Sign in with Bitcoin/Litecoin/Monero/Wownero/Grin".
This positions Smirk as infrastructure for wallet-based authentication across multiple cryptocurrencies.
+18
View File
@@ -1,5 +1,13 @@
@import "tailwindcss";
@font-face {
font-family: 'Creepster';
src: url('/fonts/Creepster-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--background: #0a0a0a;
--foreground: #ededed;
@@ -73,3 +81,13 @@ body {
font-weight: 500;
text-transform: uppercase;
}
/* Creepster text styling */
.creepster-text {
font-family: 'Creepster', cursive;
color: #fbeb0a;
text-shadow:
0 0 10px rgba(251, 235, 10, 0.6),
0 0 20px rgba(251, 235, 10, 0.4),
0 0 30px rgba(251, 235, 10, 0.2);
}
+18 -11
View File
@@ -60,14 +60,17 @@ export default function Home() {
const challenge = await getChallenge(window.location.origin);
// Sign with the selected coin
const signatures = await window.smirk.signMessage(challenge.challenge);
const sig = signatures[coin];
const result = await window.smirk.signMessage(challenge.challenge);
const sig = result.signatures.find((s) => s.asset === coin);
if (!sig) {
throw new Error(`No signature found for ${coin}`);
}
// Verify with backend
const auth = await verifySignature(challenge.challenge_id, {
asset: coin,
signature: sig.signature,
public_key: sig.public_key,
public_key: sig.publicKey,
});
// Store token and show logged in
@@ -101,14 +104,18 @@ export default function Home() {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
{/* Logo */}
<div className="mb-12 logo-glow">
<Image
src="/logo_w_creepytext.svg"
alt="Smirk Wallet"
width={220}
height={200}
priority
/>
<div className="mb-12 flex flex-col items-center">
<h1 className="creepster-text text-4xl mb-4">SMIRK WALLET</h1>
<div className="logo-glow">
<Image
src="/logo.svg"
alt="Smirk"
width={160}
height={160}
priority
/>
</div>
<p className="creepster-text text-2xl mt-4">smirk.ca$h</p>
</div>
{/* Initial state - Connect button */}
+4 -4
View File
@@ -34,8 +34,8 @@ export async function getChallenge(origin: string): Promise<ChallengeResponse> {
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Failed to get challenge');
const data = await res.json().catch(() => ({}));
throw new Error(data.error || data.message || 'Failed to get challenge');
}
return res.json();
@@ -55,8 +55,8 @@ export async function verifySignature(
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Failed to verify signature');
const data = await res.json().catch(() => ({}));
throw new Error(data.error || data.message || 'Failed to verify signature');
}
return res.json();
+7 -9
View File
@@ -9,25 +9,23 @@ interface SmirkPublicKeys {
}
interface SmirkSignature {
asset: string;
asset: 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
signature: string;
public_key: string;
publicKey: string;
}
interface SmirkSignatures {
btc: SmirkSignature;
ltc: SmirkSignature;
xmr: SmirkSignature;
wow: SmirkSignature;
grin: SmirkSignature;
interface SmirkSignResult {
message: string;
signatures: SmirkSignature[];
}
interface SmirkAPI {
isSmirk: boolean;
connect(): Promise<SmirkPublicKeys>;
disconnect(): Promise<void>;
isConnected(): Promise<boolean>;
getPublicKeys(): Promise<SmirkPublicKeys | null>;
signMessage(message: string): Promise<SmirkSignatures>;
signMessage(message: string): Promise<SmirkSignResult>;
}
interface Window {