Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions components/auth/key-exchange-login-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,31 @@ import { useKeyExchangeModal } from '@/hooks/use-key-exchange-modal'
import { useLoginModal } from '@/hooks/use-login-modal'
import { useAuth } from '@/contexts/auth-context'
import { useSettingsStore } from '@/lib/store'
import { useBuyYappModal } from '@/hooks/use-buy-yapp-modal'
import { authVaultService } from '@/lib/services/auth-vault-service'
import { tokenService } from '@/lib/services/token-service'
import { getPasskeyPrfSupport } from '@/lib/webauthn/passkey-support'
import { YAPP_TOKEN_COSTS } from '@/lib/constants'
import { KeyExchangeQR } from './key-exchange-qr'
import { KeyRegistrationFlow } from './key-registration-flow'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'

/**
* After a wallet login lands on a ready account, decide whether to prompt for
* YAPP right away: true when the balance can't cover a single post. A failed
* balance fetch is "unknown", not zero, so it never triggers the prompt.
*/
async function needsYappPrompt(identityId: string): Promise<boolean> {
try {
const balance = await tokenService.getBalance(identityId)
return balance < BigInt(YAPP_TOKEN_COSTS.post)
} catch (err) {
logger.warn('Skipping post-login YAPP prompt: balance check failed', err)
return false
}
}

/**
* A passkey is only worth offering when the vault exists, has no passkey yet,
* and the browser can actually run a PRF-capable ceremony.
Expand Down Expand Up @@ -80,12 +98,26 @@ export function KeyExchangeLoginModal() {
attemptGenerationRef.current += 1
}, [])

// Set once login succeeds on a ready account that can't afford a post. A
// wallet login yields a HIGH key, which can post but not buy YAPP — only the
// wallet holds the CRITICAL key — so rather than let the first post fail and
// bounce the user through a purchase, finishLogin opens the Buy-YAPP modal
// straight into the dash-st: wallet-signing path while the wallet is in hand.
const promptForYappRef = useRef(false)

// cancel() zeros key material in result state via clearResult
const finishLogin = useCallback(() => {
abandonAttempt()
cancel()
closeLoginModal()
close()
if (promptForYappRef.current) {
promptForYappRef.current = false
useBuyYappModal.getState().open(
'You\'re signed in! Posting, replying, and liking cost YAPP — grab some now and approve it in your wallet.',
'wallet',
)
}
}, [abandonAttempt, cancel, closeLoginModal, close])

// Attempt login and handle success/failure
Expand All @@ -96,12 +128,19 @@ export function KeyExchangeLoginModal() {

setLoginError(null)
setIsCompleting(true)
promptForYappRef.current = false
loginWithKeyExchange(identityId, loginKey, keyIndex)
.then(async () => {
.then(async (intent) => {
if (!isCurrent()) return

const offerPasskey = await shouldOfferPasskeyEnrollment(identityId)
// Accounts still needing a username or profile go through those steps
// first; the YAPP prompt only makes sense once the account is ready.
const [offerPasskey, promptForYapp] = await Promise.all([
shouldOfferPasskeyEnrollment(identityId),
intent.kind === 'ready' ? needsYappPrompt(identityId) : Promise.resolve(false),
])
if (!isCurrent()) return
promptForYappRef.current = promptForYapp

// Offer enrollment as a step inside this modal instead of closing straight away.
// isCompleting stays true so the completion effect does not re-run the login.
Expand Down Expand Up @@ -172,12 +211,19 @@ export function KeyExchangeLoginModal() {
return
}

// Login already succeeded and a YAPP prompt was decided; closing during
// the brief auto-close wait just skips the wait, it shouldn't drop the prompt.
if (promptForYappRef.current) {
finishLogin()
return
}

abandonAttempt()
setLoginError(null)
setIsCompleting(false)
cancel()
close()
}, [abandonAttempt, cancel, close, finishPasskeyStep, isAddingPasskey, passkeyOffer])
}, [abandonAttempt, cancel, close, finishLogin, finishPasskeyStep, isAddingPasskey, passkeyOffer])

// Render content based on state
const renderContent = () => {
Expand Down
37 changes: 29 additions & 8 deletions components/token/buy-yapp-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ const MODAL_TITLES: Record<ModalState, string> = {
const WALLET_SIGN_TIMEOUT_S = 300

export function BuyYappModal() {
const { isOpen, reason, close } = useBuyYappModal()
const { isOpen, reason, signing, close } = useBuyYappModal()
// Opened for a wallet-login user: skip local signing (their login key is
// HIGH and can't spend) and the paste-a-CRITICAL-key screen, and go straight
// to the dash-st: QR the wallet that just logged them in can approve.
const preferWallet = signing === 'wallet'
const { user, refreshBalance } = useAuth()

const [amount, setAmount] = useState('100')
Expand Down Expand Up @@ -170,16 +174,18 @@ export function BuyYappModal() {
}
}

// Leave the dash-st: QR screen and fall back to pasting the CRITICAL key,
// optionally with a message. Bumping the generation counter discards any
// in-flight build so a late resolve can't put a stale QR back on screen.
// Leave the dash-st: QR screen, optionally with a message. Falls back to
// pasting the CRITICAL key — or, for a wallet-login user who has no key to
// paste, back to the confirmation so they can retry in the wallet. Bumping
// the generation counter discards any in-flight build so a late resolve
// can't put a stale QR back on screen.
const exitWalletSign = useCallback((message: string | null) => {
walletSessionRef.current++
setWalletUri(null)
setWalletRemaining(null)
setError(message)
setState('needKey')
}, [])
setState(preferWallet ? 'confirming' : 'needKey')
}, [preferWallet])

/**
* Alternative to pasting the CRITICAL key: build the unsigned purchase
Expand Down Expand Up @@ -401,10 +407,25 @@ export function BuyYappModal() {
</div>
<p className="text-xs text-gray-500">{formatCoverage(amountBig)}</p>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3">
<Button onClick={() => { setState('input'); setCriticalKeyWif('') }} variant="outline" className="flex-1">Back</Button>
<Button onClick={handleBuy} className="flex-1">Confirm &amp; Buy</Button>
<Button onClick={() => { setState('input'); setError(null); setCriticalKeyWif('') }} variant="outline" className="flex-1">Back</Button>
{preferWallet ? (
<Button
onClick={() => { handleWalletSign().catch(err => logger.error('Failed to start wallet signing:', err)) }}
className="flex-1"
>
Approve in wallet
</Button>
) : (
<Button onClick={handleBuy} className="flex-1">Confirm &amp; Buy</Button>
)}
</div>
{preferWallet && (
<p className="text-xs text-gray-500 text-center">
You&apos;ll approve this purchase in the Dash wallet you just logged in with — no key ever touches this browser.
</p>
)}
</div>
)}

Expand Down
4 changes: 3 additions & 1 deletion contexts/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ interface AuthContextType {
login: (identityId: string, privateKey: string, options?: { skipUsernameCheck?: boolean }) => Promise<void>
loginWithPassword: (username: string, password: string) => Promise<void>
loginWithPasskey: (identityOrUsername?: string) => Promise<void>
loginWithKeyExchange: (identityId: string, loginKey: Uint8Array, keyIndex: number) => Promise<void>
/** Resolves with the post-login intent so the caller can tell a fully set-up account from one still needing a username/profile. */
loginWithKeyExchange: (identityId: string, loginKey: Uint8Array, keyIndex: number) => Promise<PlatformAuthIntent>
createOrUpdateUnifiedVaultFromLoginKey: (identityId: string, loginKey: Uint8Array) => Promise<void>
createOrUpdateUnifiedVaultFromAuthKey: (identityId: string, authKeyWif: string) => Promise<void>
addPasskeyWrapper: (label?: string) => Promise<void>
Expand Down Expand Up @@ -160,6 +161,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const loginWithKeyExchange = useCallback(async (identityId: string, loginKey: Uint8Array, keyIndex: number) => {
const result = await controller.loginWithLoginKey(identityId, loginKey, keyIndex)
await applyIntent(result.intent)
return result.intent
}, [applyIntent, controller])

const createOrUpdateUnifiedVaultFromLoginKey = useCallback(async (identityId: string, loginKey: Uint8Array) => {
Expand Down
19 changes: 16 additions & 3 deletions hooks/use-buy-yapp-modal.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { create } from 'zustand'
import { isInsufficientTokenError } from '@/lib/error-utils'

/**
* Which signing path the Buy-YAPP modal takes once an amount is confirmed.
* - 'local': sign with the stored login key, falling back to asking for a
* CRITICAL key when the login key is HIGH.
* - 'wallet': go straight to the dash-st: QR for a remote wallet to sign.
* Used right after a wallet (key-exchange) login: the wallet that just
* approved the login holds the CRITICAL key, and asking the user to paste
* one into the browser is exactly what that flow exists to avoid.
*/
export type BuyYappSigning = 'local' | 'wallet'

interface BuyYappModalStore {
isOpen: boolean
/** Optional reason shown at the top (e.g. "You need YAPP to post"). */
reason: string | null
open: (reason?: string) => void
signing: BuyYappSigning
open: (reason?: string, signing?: BuyYappSigning) => void
close: () => void
}

export const useBuyYappModal = create<BuyYappModalStore>((set) => ({
isOpen: false,
reason: null,
open: (reason) => set({ isOpen: true, reason: reason ?? null }),
close: () => set({ isOpen: false, reason: null }),
signing: 'local',
open: (reason, signing) => set({ isOpen: true, reason: reason ?? null, signing: signing ?? 'local' }),
close: () => set({ isOpen: false, reason: null, signing: 'local' }),
}))

/**
Expand Down
Loading