diff --git a/app/hashtag/page.tsx b/app/hashtag/page.tsx index 074ea855..4f46d9a1 100644 --- a/app/hashtag/page.tsx +++ b/app/hashtag/page.tsx @@ -17,6 +17,7 @@ import { useAuth } from '@/contexts/auth-context' import { useSettingsStore } from '@/lib/store' import { checkBlockedForAuthors } from '@/hooks/use-block' import { isCashtagStorage, cashtagStorageToDisplay } from '@/lib/post-helpers' +import { LegacyYapprLink } from '@/components/ui/legacy-yappr-link' function HashtagPageContent() { const router = useRouter() @@ -163,6 +164,7 @@ function HashtagPageContent() {

Be the first to post with {tagSymbol}{displayTag}

+ ) : ( posts.map((post, index) => ( diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 57c3536b..8c57445e 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -20,6 +20,7 @@ import { UserPlusIcon, LockClosedIcon, CloudArrowUpIcon, + NoSymbolIcon, } from '@heroicons/react/24/outline' import { Sidebar } from '@/components/layout/sidebar' import { RightSidebar } from '@/components/layout/right-sidebar' @@ -41,14 +42,18 @@ import { BlockListSettings } from '@/components/settings/block-list-settings' import { SavedAddressesSettings } from '@/components/settings/saved-addresses-settings' import { StorachaSettings } from '@/components/settings/storacha-settings' import { PinataSettings } from '@/components/settings/pinata-settings' +import { ModerationSettings } from '@/components/settings/moderation-settings' +import { YAPP_TOKEN_AUTHORITY_ID } from '@/lib/constants' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { useDashPayContactsModal } from '@/hooks/use-dashpay-contacts-modal' import { useSettingsStore } from '@/lib/store' import { CORS_PROXY_INFO } from '@/hooks/use-link-preview' import { UsernameModal } from '@/components/dpns/username-modal' -type SettingsSection = 'main' | 'account' | 'contacts' | 'notifications' | 'privacy' | 'privateFeed' | 'storage' | 'appearance' | 'about' -const VALID_SECTIONS: SettingsSection[] = ['main', 'account', 'contacts', 'notifications', 'privacy', 'privateFeed', 'storage', 'appearance', 'about'] +type SettingsSection = 'main' | 'account' | 'contacts' | 'notifications' | 'privacy' | 'privateFeed' | 'storage' | 'appearance' | 'about' | 'moderation' +const VALID_SECTIONS: SettingsSection[] = ['main', 'account', 'contacts', 'notifications', 'privacy', 'privateFeed', 'storage', 'appearance', 'about', 'moderation'] + +const MODERATION_SECTION = { id: 'moderation', label: 'Moderation', icon: NoSymbolIcon, description: 'Freeze or slash YAPP balances (token authority only)' } const settingsSections = [ { id: 'account', label: 'Account', icon: UserIcon, description: 'Manage your account details' }, @@ -85,6 +90,7 @@ function SettingsPage() { const router = useRouter() const searchParams = useSearchParams() const { user, logout } = useAuth() + const isAuthority = user?.identityId === YAPP_TOKEN_AUTHORITY_ID const { theme, setTheme } = useTheme() const linkPreviewsChoice = useSettingsStore((s) => s.linkPreviewsChoice) const setLinkPreviewsChoice = useSettingsStore((s) => s.setLinkPreviewsChoice) @@ -206,7 +212,7 @@ function SettingsPage() { const renderMainSettings = () => (
- {settingsSections.map((section) => ( + {(isAuthority ? [...settingsSections, MODERATION_SECTION] : settingsSections).map((section) => (
)} + + {!hasMore && posts && posts.length > 0 && ( +
+

You've reached the end.

+ +
+ )} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index bafe9af6..e9a3f380 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -39,6 +39,7 @@ import { useAppStore } from '@/lib/store' import { useNotificationStore } from '@/lib/stores/notification-store' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { UserAvatar } from '@/components/ui/avatar-image' +import { YappBalanceItem } from '@/components/token/yapp-balance-item' import { useAuth } from '@/contexts/auth-context' import { notificationService } from '@/lib/services' import { useLoginModal } from '@/hooks/use-login-modal' @@ -308,8 +309,13 @@ export function Sidebar() { onClick={async (e) => { e.stopPropagation() setIsRefreshingBalance(true) - await refreshBalance() - setIsRefreshingBalance(false) + try { + await refreshBalance() + } catch (error) { + logger.error('Failed to refresh balance:', error) + } finally { + setIsRefreshingBalance(false) + } }} disabled={isRefreshingBalance} className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" @@ -320,7 +326,8 @@ export function Sidebar() { - + diff --git a/components/post/post-card.tsx b/components/post/post-card.tsx index 25e2cc4e..c20e35a6 100644 --- a/components/post/post-card.tsx +++ b/components/post/post-card.tsx @@ -38,6 +38,7 @@ import { EmbeddedPostCard, EmbeddedPostSkeleton } from './embedded-post-card' import { EmbeddedBlogPostCard, isEmbeddedBlogPostLike } from '@/components/blog/embedded-blog-post-card' import { ProfileHoverCard } from '@/components/profile/profile-hover-card' import { useTipModal } from '@/hooks/use-tip-modal' +import { handleInsufficientYapp } from '@/hooks/use-buy-yapp-modal' import { useBlock } from '@/hooks/use-block' import { useFollow } from '@/hooks/use-follow' import { useHashtagValidation } from '@/hooks/use-hashtag-validation' @@ -364,7 +365,9 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e setLiked(wasLiked) setLikes(prevLikes) logger.error('Like error:', error) - toast.error('Failed to update like. Please try again.') + if (!handleInsufficientYapp(error, 'You need YAPP to like posts. Buy some to continue.')) { + toast.error('Failed to update like. Please try again.') + } } finally { setLikeLoading(false) } @@ -397,7 +400,9 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e setReposted(wasReposted) setReposts(prevReposts) logger.error('Repost error:', error) - toast.error('Failed to update repost. Please try again.') + if (!handleInsufficientYapp(error, 'You need YAPP to repost. Buy some to continue.')) { + toast.error('Failed to update repost. Please try again.') + } } finally { setRepostLoading(false) } diff --git a/components/post/tip-modal.tsx b/components/post/tip-modal.tsx index 29c78ac0..13af1e1a 100644 --- a/components/post/tip-modal.tsx +++ b/components/post/tip-modal.tsx @@ -6,6 +6,7 @@ import * as Dialog from '@radix-ui/react-dialog' import { XMarkIcon, CurrencyDollarIcon, QrCodeIcon, WalletIcon, BookmarkIcon } from '@heroicons/react/24/outline' import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/solid' import { motion, AnimatePresence } from 'framer-motion' +import toast from 'react-hot-toast' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/spinner' import { useTipModal } from '@/hooks/use-tip-modal' @@ -204,6 +205,18 @@ export function TipModal() { // Update global balance in auth context (persists to localStorage) refreshBalance().catch(err => logger.error('Failed to refresh balance:', err)) + // The tip (credit transfer) succeeded, but the public "tip" reply is a + // YAPP-costed doc — tell the user if it couldn't be posted rather than + // letting it vanish silently, and only blame YAPP when that was the cause. + if (result.announcementPosted === false) { + toast( + result.announcementError === 'INSUFFICIENT_YAPP' + ? 'Tip sent — but the public tip note couldn’t be posted (needs YAPP).' + : 'Tip sent — but the public tip note couldn’t be posted. You can reply to the post manually.', + { icon: '⚠️', duration: 5000 } + ) + } + // If key was manually entered and not already saved, offer to save if (keySource === 'manual' && usedTransferKeyRef.current && !hasTransferKey(user.identityId)) { setState('save-prompt') diff --git a/components/providers.tsx b/components/providers.tsx index cbec400d..1cb79710 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -11,6 +11,7 @@ import { MentionRecoveryModal } from '@/components/post/mention-recovery-modal' import { DeleteConfirmationModal } from '@/components/post/delete-confirmation-modal' import { DashPayContactsModal } from '@/components/contacts/dashpay-contacts-modal' import { EncryptionKeyModal } from '@/components/auth/encryption-key-modal' +import { BuyYappModal } from '@/components/token/buy-yapp-modal' export function Providers({ children }: { children: React.ReactNode }) { return ( @@ -26,6 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) { + diff --git a/components/settings/moderation-settings.tsx b/components/settings/moderation-settings.tsx new file mode 100644 index 00000000..eefc20aa --- /dev/null +++ b/components/settings/moderation-settings.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useState } from 'react' +import toast from 'react-hot-toast' +import { NoSymbolIcon, ArrowUturnLeftIcon, FireIcon } from '@heroicons/react/24/outline' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { useAuth } from '@/contexts/auth-context' +import { tokenService } from '@/lib/services/token-service' + +/** + * Owner-only YAPP moderation: freeze (suspend), unfreeze (reinstate), and + * destroyFrozen (slash the staked balance). Rendered only for the token + * authority identity (gated in the settings page). + */ +export function ModerationSettings() { + const { user } = useAuth() + const [targetId, setTargetId] = useState('') + const [note, setNote] = useState('') + const [busy, setBusy] = useState(null) + + const run = async (action: 'freeze' | 'unfreeze' | 'destroyFrozen') => { + if (!user) return + const id = targetId.trim() + if (!id) { + toast.error('Enter the identity ID to moderate') + return + } + if (action === 'destroyFrozen' && !window.confirm( + `Permanently burn ${id}'s YAPP balance? This slashes their stake and cannot be undone. The account must already be frozen.` + )) return + + setBusy(action) + const fn = action === 'freeze' ? tokenService.freeze + : action === 'unfreeze' ? tokenService.unfreeze + : tokenService.destroyFrozen + const result = await fn.call(tokenService, user.identityId, id, note.trim() || undefined) + setBusy(null) + + if (result.success) { + toast.success( + action === 'freeze' ? 'Identity frozen (suspended)' + : action === 'unfreeze' ? 'Identity unfrozen (reinstated)' + : 'Frozen balance destroyed (slashed)' + ) + } else { + toast.error(result.error || 'Action failed') + } + } + + return ( + + + YAPP Moderation + + Freeze suspends an account (blocks posting and transfers immediately). Slash permanently + burns a frozen account's YAPP stake. Use unfreeze to reinstate. + + + +
+ + setTargetId(e.target.value)} + placeholder="Base58 identity ID to moderate" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-neutral-800 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-yappr-500" + /> +
+
+ + setNote(e.target.value)} + placeholder="Reason recorded on-chain" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-yappr-500" + /> +
+
+ + + +
+
+
+ ) +} diff --git a/components/token/buy-yapp-modal.tsx b/components/token/buy-yapp-modal.tsx new file mode 100644 index 00000000..78e69e1e --- /dev/null +++ b/components/token/buy-yapp-modal.tsx @@ -0,0 +1,376 @@ +'use client' + +import { logger } from '@/lib/logger' +import { useState, useEffect } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { XMarkIcon, SparklesIcon } from '@heroicons/react/24/outline' +import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/solid' +import { motion, AnimatePresence } from 'framer-motion' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/ui/spinner' +import { useBuyYappModal } from '@/hooks/use-buy-yapp-modal' +import { useAuth } from '@/contexts/auth-context' +import { tokenService, MIN_YAPP_PURCHASE } from '@/lib/services/token-service' +import { identityService } from '@/lib/services/identity-service' +import { tipService } from '@/lib/services/tip-service' +import { YAPP_TOKEN_COSTS } from '@/lib/constants' + +// Preset purchase amounts in whole YAPP (must be >= the on-chain minimum of 100). +const PRESETS = [100, 500, 1000, 5000] + +/** "≈ 10 posts, 33 replies, or 100 likes" for a given YAPP amount. */ +function formatCoverage(amount: bigint): string { + const posts = amount / BigInt(YAPP_TOKEN_COSTS.post) + const replies = amount / BigInt(YAPP_TOKEN_COSTS.reply) + const likes = amount / BigInt(YAPP_TOKEN_COSTS.like) + return `≈ ${posts.toString()} posts, ${replies.toString()} replies, or ${likes.toString()} likes` +} + +const CREDITS_PER_DASH = BigInt(100000000000) // 1 DASH = 1e11 platform credits + +/** BigInt-safe credits → "X.XXXX DASH" (or "N credits" for sub-0.0001 DASH). */ +function formatCreditsAsDash(credits: bigint): string { + const whole = credits / CREDITS_PER_DASH + const frac = ((credits % CREDITS_PER_DASH) * BigInt(10000)) / CREDITS_PER_DASH + if (whole === BigInt(0) && frac === BigInt(0)) return `${credits.toString()} credits` + return `${whole.toString()}.${frac.toString().padStart(4, '0')} DASH` +} + +type ModalState = 'input' | 'confirming' | 'needKey' | 'processing' | 'success' | 'error' + +export function BuyYappModal() { + const { isOpen, reason, close } = useBuyYappModal() + const { user, refreshBalance } = useAuth() + + const [amount, setAmount] = useState('100') + const [state, setState] = useState('input') + const [error, setError] = useState(null) + const [yappBalance, setYappBalance] = useState(null) + const [creditBalance, setCreditBalance] = useState(null) + const [pricePerToken, setPricePerToken] = useState(null) + const [loading, setLoading] = useState(false) + const [showCosts, setShowCosts] = useState(false) + // CRITICAL key the user pastes when their login key is HIGH — kept in + // component state only for the purchase, never persisted. + const [criticalKeyWif, setCriticalKeyWif] = useState('') + + useEffect(() => { + if (isOpen && user) { + setLoading(true) + void Promise.all([ + tokenService.getBalance(user.identityId).then(setYappBalance).catch(() => setYappBalance(null)), + identityService.getBalance(user.identityId).then(b => setCreditBalance(b.confirmed)).catch(() => setCreditBalance(null)), + tokenService.getPricePerToken().then(setPricePerToken).catch(() => setPricePerToken(null)), + ]).finally(() => setLoading(false)) + } + }, [isOpen, user]) + + useEffect(() => { + if (!isOpen) { + setAmount('100') + setState('input') + setError(null) + setShowCosts(false) + setCriticalKeyWif('') + } + }, [isOpen]) + + // Keep token/credit values as BigInt end-to-end so the signed amount and cap + // can never diverge from what the UI showed. + const amountBig = /^\d+$/.test(amount) ? BigInt(amount) : BigInt(0) + const costCredits = pricePerToken !== null ? pricePerToken * amountBig : null + const costDashLabel = costCredits !== null ? formatCreditsAsDash(costCredits) : '—' + + const handleAmountChange = (value: string) => { + if (/^\d*$/.test(value)) { + setAmount(value) + setError(null) + } + } + + const handleContinue = () => { + if (amountBig < MIN_YAPP_PURCHASE) { + setError(`Minimum purchase is ${MIN_YAPP_PURCHASE} YAPP`) + return + } + if (costCredits === null) { + setError('Price unavailable right now — please try again') + return + } + if (creditBalance !== null && costCredits > BigInt(Math.floor(creditBalance))) { + setError('Not enough DASH credits for this purchase') + return + } + setState('confirming') + setError(null) + } + + const handleBuy = async () => { + if (!user || costCredits === null) return + setState('processing') + // Cap the spend at the exact cost the user just confirmed. If the on-chain + // price rose since, the transition is rejected rather than silently overspending. + const enteredKey = criticalKeyWif.trim() + const result = await tokenService.buyYapp(user.identityId, amountBig, costCredits, enteredKey || undefined) + if (result.success) { + setCriticalKeyWif('') + tokenService.getBalance(user.identityId).then(setYappBalance).catch(() => {}) + refreshBalance().catch(err => logger.error('Failed to refresh balance:', err)) + // Notify other YAPP balance views (e.g. the sidebar dropdown) to refresh. + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('yapp-balance-changed')) + setState('success') + } else if (result.errorCode === 'NEEDS_CRITICAL_KEY') { + // Login key is HIGH but purchases must be signed with CRITICAL — ask for + // it. If a key was already entered, it didn't match a CRITICAL key. + setError(enteredKey ? 'That key doesn\'t match a CRITICAL key on your identity — check it and try again' : null) + setState('needKey') + } else { + // Leaving the needKey flow — drop the entered key so a later retry can't + // silently sign with it without the user re-confirming. + setCriticalKeyWif('') + setState('error') + setError(result.error || 'Purchase failed') + } + } + + const handleClose = () => { + if (state === 'processing') return + close() + } + + return ( + + + {isOpen && ( + + + + + e.stopPropagation()} + > + + + {state === 'success' ? 'YAPP Purchased!' : state === 'error' ? 'Purchase Failed' : state === 'needKey' ? 'Authorize Purchase' : 'Buy YAPP'} + + + Buy YAPP tokens to post, comment, and like on Yappr. + + + + + {state === 'input' && ( +
+ {reason && ( +

{reason}

+ )} +

+ YAPP powers posting, comments, and likes. Buy in bundles of {MIN_YAPP_PURCHASE.toString()}+ — the upfront stake keeps spam out.{' '} + +

+ + {showCosts && ( +
+
+ Post + {YAPP_TOKEN_COSTS.post} YAPP +
+
+ Reply + {YAPP_TOKEN_COSTS.reply} YAPP +
+
+ Like + {YAPP_TOKEN_COSTS.like} YAPP +
+
+ Repost + {YAPP_TOKEN_COSTS.repost} YAPP +
+

+ Costs are fixed in the contract and can't change under you. Following, bookmarking, and browsing are free. +

+
+ )} + +
+
Your YAPP: {loading ? '…' : yappBalance !== null ? yappBalance.toString() : '—'}
+
Your credits: {loading ? '…' : creditBalance !== null ? tipService.formatDash(tipService.creditsToDash(creditBalance)) : '—'}
+
+ +
+ + handleAmountChange(e.target.value)} + placeholder="100" + className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-neutral-800 text-lg font-mono focus:outline-none focus:ring-2 focus:ring-yappr-500 focus:border-transparent" + /> +
+ +
+ {PRESETS.map((preset) => ( + + ))} +
+ +
+
+ Cost + {costDashLabel} +
+ {amountBig > BigInt(0) && ( +

{formatCoverage(amountBig)}

+ )} +
+ + {error &&

{error}

} + + +
+ )} + + {state === 'confirming' && ( +
+
+
+ Buying + {amountBig.toString()} YAPP +
+
+ Cost + {costDashLabel} +
+

{formatCoverage(amountBig)}

+
+
+ + +
+
+ )} + + {state === 'needKey' && ( +
+

+ Buying YAPP spends DASH credits, and Dash Platform requires your{' '} + CRITICAL key to + authorize that — your login key is a HIGH key, which can post but not spend. + Paste your CRITICAL private key below. It signs this purchase locally and is + never stored or sent anywhere. +

+
+ Buying {amountBig.toString()} YAPP + {costDashLabel} +
+ { setCriticalKeyWif(e.target.value); setError(null) }} + onKeyDown={(e) => { + if (e.key === 'Enter' && criticalKeyWif.trim()) { + e.preventDefault() + handleBuy().catch(err => logger.error('Failed to buy YAPP:', err)) + } + }} + placeholder="CRITICAL private key (WIF)" + autoComplete="off" + className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-neutral-800 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-yappr-500 focus:border-transparent" + /> + {error &&

{error}

} +
+ + +
+
+ )} + + {state === 'processing' && ( +
+ +

Purchasing YAPP…

+

Please wait, this may take a moment.

+
+ )} + + {state === 'success' && ( +
+ +
+

Purchased {amountBig.toString()} YAPP

+ {yappBalance !== null && ( +

New balance: {yappBalance.toString()} YAPP

+ )} +
+ +
+ )} + + {state === 'error' && ( +
+ +
+

Purchase Failed

+

{error}

+
+
+ + +
+
+ )} +
+
+
+
+
+ )} +
+
+ ) +} diff --git a/components/token/yapp-balance-item.tsx b/components/token/yapp-balance-item.tsx new file mode 100644 index 00000000..3016f3c7 --- /dev/null +++ b/components/token/yapp-balance-item.tsx @@ -0,0 +1,63 @@ +'use client' + +import { useEffect, useState } from 'react' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { SparklesIcon } from '@heroicons/react/24/outline' +import { useAuth } from '@/contexts/auth-context' +import { tokenService } from '@/lib/services/token-service' +import { useBuyYappModal } from '@/hooks/use-buy-yapp-modal' + +/** + * YAPP balance + "Buy" row for the account dropdown. Mirrors the credits + * Balance row's styling and sits directly beneath it. + */ +export function YappBalanceItem() { + const { user } = useAuth() + const openBuy = useBuyYappModal((s) => s.open) + const [yapp, setYapp] = useState(null) + const identityId = user?.identityId + + useEffect(() => { + // Clear the previous identity's balance immediately so an account switch + // never keeps showing the old user's YAPP while the new fetch is in flight. + setYapp(null) + if (!identityId) return + let cancelled = false + const refresh = () => { + tokenService.getBalance(identityId) + .then((b) => { if (!cancelled) setYapp(b) }) + .catch(() => { if (!cancelled) setYapp(null) }) + } + refresh() + window.addEventListener('yapp-balance-changed', refresh) + return () => { + cancelled = true + window.removeEventListener('yapp-balance-changed', refresh) + } + }, [identityId]) + + if (!user) return null + + return ( + <> + {/* The whole row is the actionable menu item so it's reachable/operable by + keyboard (Enter/Space) and avoids an interactive control nested in a + role="menuitem". The "Buy" pill is a visual affordance only. */} + openBuy()} + > +
+
+
YAPP
+
{yapp !== null ? yapp.toString() : '…'}
+
+ + Buy + +
+
+ + + ) +} diff --git a/components/ui/legacy-yappr-link.tsx b/components/ui/legacy-yappr-link.tsx new file mode 100644 index 00000000..3e01d882 --- /dev/null +++ b/components/ui/legacy-yappr-link.tsx @@ -0,0 +1,21 @@ +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' +import { LEGACY_APP_URL } from '@/lib/constants' + +/** + * Escape hatch shown on empty / end-of-feed states: content created before the + * v4 contract cutover lives on the previous Yappr deployment. Sends users there + * for the "old stuff" when they reach the end of what the current app has. + */ +export function LegacyYapprLink({ className = '' }: { className?: string }) { + return ( + + Looking for older posts? Browse the previous version of Yappr + + + ) +} diff --git a/components/ui/loading-state.tsx b/components/ui/loading-state.tsx index d60d7fdb..f653c0dc 100644 --- a/components/ui/loading-state.tsx +++ b/components/ui/loading-state.tsx @@ -11,6 +11,8 @@ export interface LoadingStateProps { loadingText?: string emptyText?: string emptyDescription?: string + /** Optional node rendered below the empty description (e.g. a link to the old app). */ + emptyAction?: React.ReactNode className?: string } @@ -23,6 +25,7 @@ export function LoadingState({ loadingText = 'Loading...', emptyText = 'No data found', emptyDescription = 'There\'s nothing here yet.', + emptyAction, className = '' }: LoadingStateProps) { if (loading) { @@ -79,6 +82,7 @@ export function LoadingState({

{emptyDescription}

+ {emptyAction &&
{emptyAction}
} ) } diff --git a/contracts/yappr-social-contract-v2.json b/contracts/yappr-social-contract-v2.json new file mode 100644 index 00000000..b96e1289 --- /dev/null +++ b/contracts/yappr-social-contract-v2.json @@ -0,0 +1,1002 @@ +{ + "$formatVersion": "1", + "version": 1, + "documentSchemas": { + "like": { + "type": "object", + "indices": [ + { + "name": "postAndOwner", + "unique": true, + "properties": [ + { "postId": "asc" }, + { "$ownerId": "asc" } + ] + }, + { + "name": "postOwnerLikes", + "properties": [ + { "postOwnerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "byPost", + "properties": [ + { "postId": "asc" } + ], + "countable": true + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "postId" + ], + "properties": { + "postId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "ID of the liked post or reply", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "postOwnerId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 1, + "byteArray": true, + "description": "Identity ID of the post owner (for efficient notification queries)", + "contentMediaType": "application/x.dash.dpp.identifier" + } + }, + "tokenCost": { + "create": { "tokenPosition": 0, "amount": 1 } + }, + "description": "A like on a post or reply", + "additionalProperties": false + }, + "post": { + "type": "object", + "indices": [ + { + "name": "ownerAndTime", + "properties": [ + { "$ownerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "languageTimeline", + "properties": [ + { "language": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "quotedPostAndOwner", + "unique": true, + "properties": [ + { "quotedPostId": "asc" }, + { "$ownerId": "asc" } + ] + }, + { + "name": "quotedPostOwnerAndTime", + "properties": [ + { "quotedPostOwnerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "quoteCount", + "properties": [ + { "quotedPostId": "asc" } + ], + "countable": true + } + ], + "required": [ + "$createdAt", + "language" + ], + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 500, + "minLength": 0, + "description": "Post content (public content or teaser for private posts). Empty for pure reposts." + }, + "mediaUrl": { + "type": "string", + "pattern": "^https?://.+$", + "position": 1, + "maxLength": 512, + "description": "URL to media file (temporary field - will support multiple media in future)" + }, + "quotedPostId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 2, + "byteArray": true, + "description": "ID of quoted post", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "language": { + "type": "string", + "pattern": "^[a-z]{2}$", + "position": 3, + "maxLength": 2, + "description": "Language code of the post" + }, + "sensitive": { + "type": "boolean", + "position": 4, + "description": "Whether post contains sensitive content" + }, + "encryptedContent": { + "type": "array", + "maxItems": 1024, + "minItems": 1, + "position": 5, + "byteArray": true, + "description": "Encrypted private post content (AEAD ciphertext). Presence indicates private post." + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295, + "position": 6, + "description": "Epoch number for private post key derivation" + }, + "nonce": { + "type": "array", + "maxItems": 24, + "minItems": 24, + "position": 7, + "byteArray": true, + "description": "24-byte nonce for XChaCha20-Poly1305 encryption" + }, + "quotedPostOwnerId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 8, + "byteArray": true, + "description": "Identity ID of the quoted post owner (for notification queries)", + "contentMediaType": "application/x.dash.dpp.identifier" + } + }, + "tokenCost": { + "create": { "tokenPosition": 0, "amount": 10 } + }, + "description": "A top-level post in the social network (not a reply)", + "additionalProperties": false + }, + "reply": { + "type": "object", + "indices": [ + { + "name": "ownerAndTime", + "properties": [ + { "$ownerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "parentAndTime", + "properties": [ + { "parentId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "parentOwnerAndTime", + "properties": [ + { "parentOwnerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "byParent", + "properties": [ + { "parentId": "asc" } + ], + "countable": true + } + ], + "required": [ + "$createdAt", + "parentId", + "parentOwnerId" + ], + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 500, + "minLength": 0, + "description": "Reply content" + }, + "mediaUrl": { + "type": "string", + "pattern": "^https?://.+$", + "position": 1, + "maxLength": 512, + "description": "URL to media file" + }, + "parentId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 2, + "byteArray": true, + "description": "ID of post or reply being replied to", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "parentOwnerId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 3, + "byteArray": true, + "description": "Owner of parent (for notifications)", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "sensitive": { + "type": "boolean", + "position": 4, + "description": "Whether reply contains sensitive content" + }, + "encryptedContent": { + "type": "array", + "maxItems": 1024, + "minItems": 1, + "position": 5, + "byteArray": true, + "description": "Encrypted reply content for private threads" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295, + "position": 6, + "description": "Epoch number for private reply key derivation" + }, + "nonce": { + "type": "array", + "maxItems": 24, + "minItems": 24, + "position": 7, + "byteArray": true, + "description": "24-byte nonce for XChaCha20-Poly1305 encryption" + } + }, + "tokenCost": { + "create": { "tokenPosition": 0, "amount": 3 } + }, + "description": "A reply to a post or another reply", + "additionalProperties": false + }, + "repost": { + "type": "object", + "indices": [ + { + "name": "ownerAndPost", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "postId": "asc" } + ] + }, + { + "name": "byPost", + "properties": [ + { "postId": "asc" } + ], + "countable": true + }, + { + "name": "ownerAndTime", + "properties": [ + { "$ownerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "postOwnerAndTime", + "properties": [ + { "postOwnerId": "asc" }, + { "$createdAt": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "postId", + "postOwnerId" + ], + "properties": { + "postId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "ID of the reposted post or reply", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "postOwnerId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 1, + "byteArray": true, + "description": "Owner of the reposted post (for notification queries)", + "contentMediaType": "application/x.dash.dpp.identifier" + } + }, + "tokenCost": { + "create": { "tokenPosition": 0, "amount": 1 } + }, + "description": "A pure repost (boost) of a post or reply", + "additionalProperties": false + }, + "block": { + "type": "object", + "indices": [ + { + "name": "ownerAndBlocked", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "blockedId": "asc" } + ] + }, + { + "name": "ownerBlocks", + "properties": [ + { "$ownerId": "asc" }, + { "$createdAt": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "blockedId" + ], + "properties": { + "blockedId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "Identity ID of the blocked user", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "position": 1, + "description": "Optional public reason/message for blocking" + } + }, + "description": "A block relationship with optional public message/reason", + "additionalProperties": false + }, + "blockFilter": { + "type": "object", + "indices": [ + { + "name": "owner", + "unique": true, + "properties": [ + { "$ownerId": "asc" } + ] + } + ], + "required": [ + "$createdAt", + "$updatedAt", + "filterData" + ], + "properties": { + "filterData": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 5000, + "position": 0, + "description": "Serialized bloom filter data (up to 5KB)" + }, + "itemCount": { + "type": "integer", + "minimum": 0, + "position": 1, + "description": "Number of items added to the filter" + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 255, + "position": 2, + "description": "Bloom filter parameters version for forward compatibility" + } + }, + "description": "Bloom filter of blocked users for efficient probabilistic matching. One per user.", + "additionalProperties": false + }, + "blockFollow": { + "type": "object", + "indices": [ + { + "name": "owner", + "unique": true, + "properties": [ + { "$ownerId": "asc" } + ] + } + ], + "required": [ + "$createdAt", + "$updatedAt", + "followedBlockers" + ], + "properties": { + "followedBlockers": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 3200, + "position": 0, + "description": "Encoded array of user IDs whose blocks are inherited (max 100 users * 32 bytes)" + } + }, + "description": "Tracks which users' block lists this user inherits (hard blocks). One per user.", + "additionalProperties": false + }, + "follow": { + "type": "object", + "indices": [ + { + "name": "ownerAndFollowing", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "followingId": "asc" } + ] + }, + { + "name": "following", + "properties": [ + { "$ownerId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "followers", + "properties": [ + { "followingId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "followerCount", + "properties": [ + { "followingId": "asc" } + ], + "countable": true + }, + { + "name": "followingCount", + "properties": [ + { "$ownerId": "asc" } + ], + "countable": true + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "followingId" + ], + "properties": { + "followingId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "Identity ID of the user being followed", + "contentMediaType": "application/x.dash.dpp.identifier" + } + }, + "description": "A follow relationship between users", + "additionalProperties": false + }, + "profile": { + "type": "object", + "indices": [ + { + "name": "owner", + "unique": true, + "properties": [ + { "$ownerId": "asc" } + ] + } + ], + "required": [ + "$createdAt", + "displayName" + ], + "properties": { + "bio": { + "type": "string", + "position": 1, + "maxLength": 160, + "description": "User biography" + }, + "website": { + "type": "string", + "pattern": "^https?://.+$", + "position": 4, + "maxLength": 100, + "description": "User's website" + }, + "avatarId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 2, + "byteArray": true, + "description": "Reference to the user's avatar document", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "location": { + "type": "string", + "position": 5, + "maxLength": 50, + "description": "User location" + }, + "bannerUrl": { + "type": "string", + "pattern": "^https?://.+$", + "position": 3, + "maxLength": 512, + "description": "URL to profile banner image" + }, + "displayName": { + "type": "string", + "position": 0, + "maxLength": 50, + "minLength": 1, + "description": "Display name shown on profile" + } + }, + "description": "User profile information", + "additionalProperties": false + }, + "bookmark": { + "type": "object", + "indices": [ + { + "name": "ownerAndPost", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "postId": "asc" } + ] + }, + { + "name": "ownerBookmarks", + "properties": [ + { "$ownerId": "asc" }, + { "$createdAt": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "postId" + ], + "properties": { + "postId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "ID of the bookmarked post or reply", + "contentMediaType": "application/x.dash.dpp.identifier" + } + }, + "description": "A bookmarked post or reply", + "additionalProperties": false + }, + "followRequest": { + "type": "object", + "indices": [ + { + "name": "targetAndRequester", + "unique": true, + "properties": [ + { "targetId": "asc" }, + { "$ownerId": "asc" } + ] + }, + { + "name": "target", + "properties": [ + { "targetId": "asc" }, + { "$createdAt": "asc" } + ] + } + ], + "required": [ + "$createdAt", + "targetId" + ], + "properties": { + "targetId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "Identity ID of the private feed owner being requested", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "publicKey": { + "type": "array", + "maxItems": 33, + "minItems": 33, + "position": 1, + "byteArray": true, + "description": "Requester's secp256k1 public key (compressed, 33 bytes) - required if only hash160 on-chain" + } + }, + "description": "A request to follow a user's private feed", + "additionalProperties": false + }, + "privateFeedGrant": { + "type": "object", + "indices": [ + { + "name": "ownerAndRecipient", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "recipientId": "asc" } + ] + }, + { + "name": "ownerAndLeaf", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "leafIndex": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "recipientId", + "leafIndex", + "epoch", + "encryptedPayload" + ], + "properties": { + "recipientId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "Identity ID of the approved private follower", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "leafIndex": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "position": 1, + "description": "Assigned leaf index in the key tree (0-1023 for 1024-leaf tree)" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295, + "position": 2, + "description": "Epoch at time of grant" + }, + "encryptedPayload": { + "type": "array", + "maxItems": 600, + "minItems": 1, + "position": 3, + "byteArray": true, + "description": "ECIES-encrypted payload containing path keys and current CEK (~485 bytes)" + } + }, + "description": "A grant giving a user access to decrypt private feed posts", + "additionalProperties": false + }, + "privateFeedRekey": { + "type": "object", + "indices": [ + { + "name": "ownerAndEpoch", + "unique": true, + "properties": [ + { "$ownerId": "asc" }, + { "epoch": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "epoch", + "revokedLeaf", + "packets", + "encryptedCEK" + ], + "properties": { + "epoch": { + "type": "integer", + "minimum": 2, + "maximum": 4294967295, + "position": 0, + "description": "New epoch number after this rekey (starts at 2 for first revocation)" + }, + "revokedLeaf": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "position": 1, + "description": "Leaf index that was revoked" + }, + "packets": { + "type": "array", + "maxItems": 2048, + "minItems": 1, + "position": 2, + "byteArray": true, + "description": "Binary-encoded rekey packets for LKH key update (~1.2KB typical)" + }, + "encryptedCEK": { + "type": "array", + "maxItems": 48, + "minItems": 48, + "position": 3, + "byteArray": true, + "description": "CEK[epoch] encrypted under new root key (32 bytes + 16 byte auth tag)" + } + }, + "description": "A rekey document created when revoking a private feed follower.", + "additionalProperties": false + }, + "privateFeedState": { + "type": "object", + "canBeDeleted": false, + "indices": [ + { + "name": "owner", + "unique": true, + "properties": [ + { "$ownerId": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "treeCapacity", + "maxEpoch", + "encryptedSeed" + ], + "properties": { + "treeCapacity": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "position": 0, + "description": "Key tree capacity (typically 1024 for 1024 max private followers)" + }, + "maxEpoch": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295, + "position": 1, + "description": "Maximum pre-generated epoch chain length (typically 2000)" + }, + "encryptedSeed": { + "type": "array", + "maxItems": 256, + "minItems": 1, + "position": 2, + "byteArray": true, + "description": "ECIES-encrypted feed seed (33 byte ephemeral pubkey + encrypted payload)" + } + }, + "description": "Private feed state document created when enabling a private feed. IMMUTABLE - cannot be deleted.", + "additionalProperties": false + }, + "postHashtag": { + "type": "object", + "indices": [ + { + "name": "hashtagsByTime", + "properties": [ + { "$createdAt": "asc" } + ] + }, + { + "name": "hashtagAndTime", + "properties": [ + { "hashtag": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "postAndHashtag", + "unique": true, + "properties": [ + { "postId": "asc" }, + { "hashtag": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "postId", + "hashtag" + ], + "properties": { + "postId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "ID of the post containing the hashtag", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "hashtag": { + "type": "string", + "pattern": "^[a-z0-9_]{1,63}$", + "position": 1, + "maxLength": 63, + "minLength": 1, + "description": "Lowercase hashtag without # prefix" + } + }, + "description": "A hashtag associated with a post", + "additionalProperties": false + }, + "postMention": { + "type": "object", + "indices": [ + { + "name": "mentionedUserAndTime", + "properties": [ + { "mentionedUserId": "asc" }, + { "$createdAt": "asc" } + ] + }, + { + "name": "postAndMentioned", + "unique": true, + "properties": [ + { "postId": "asc" }, + { "mentionedUserId": "asc" } + ] + } + ], + "documentsMutable": false, + "required": [ + "$createdAt", + "postId", + "mentionedUserId" + ], + "properties": { + "postId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 0, + "byteArray": true, + "description": "ID of the post containing the mention", + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "mentionedUserId": { + "type": "array", + "maxItems": 32, + "minItems": 32, + "position": 1, + "byteArray": true, + "description": "Identity ID of the mentioned user", + "contentMediaType": "application/x.dash.dpp.identifier" + } + }, + "description": "A mention of a user in a post", + "additionalProperties": false + } + }, + "tokens": { + "0": { + "$formatVersion": "0", + "conventions": { + "$formatVersion": "0", + "localizations": { + "en": { + "$formatVersion": "0", + "shouldCapitalize": true, + "singularForm": "yapp", + "pluralForm": "yapps" + } + }, + "decimals": 0 + }, + "baseSupply": 1000000, + "maxSupply": null, + "startAsPaused": false, + "allowTransferToFrozenBalance": false, + "distributionRules": { + "$formatVersion": "0", + "changeDirectPurchasePricingRules": { + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false + } + }, + "freezeRules": { + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false + }, + "unfreezeRules": { + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false + }, + "destroyFrozenFundsRules": { + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false + }, + "emergencyActionRules": { + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false + } + } + } +} diff --git a/hooks/use-buy-yapp-modal.ts b/hooks/use-buy-yapp-modal.ts new file mode 100644 index 00000000..babb41fd --- /dev/null +++ b/hooks/use-buy-yapp-modal.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand' +import { isInsufficientTokenError } from '@/lib/error-utils' + +interface BuyYappModalStore { + isOpen: boolean + /** Optional reason shown at the top (e.g. "You need YAPP to post"). */ + reason: string | null + open: (reason?: string) => void + close: () => void +} + +export const useBuyYappModal = create((set) => ({ + isOpen: false, + reason: null, + open: (reason) => set({ isOpen: true, reason: reason ?? null }), + close: () => set({ isOpen: false, reason: null }), +})) + +/** + * If `error` is an insufficient-YAPP failure, open the Buy-YAPP modal with + * `reason` and return true (handled). Otherwise return false so the caller can + * surface its own error. Shared by post/reply/like/repost failure paths. + */ +export function handleInsufficientYapp(error: unknown, reason: string): boolean { + if (isInsufficientTokenError(error)) { + useBuyYappModal.getState().open(reason) + return true + } + return false +} diff --git a/lib/constants.ts b/lib/constants.ts index 64b7cddf..19886434 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -3,7 +3,21 @@ */ // Contract IDs -export const YAPPR_CONTRACT_ID = 'EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F' // Testnet - v10 +export const YAPPR_CONTRACT_ID = 'AyBNQV6qSLY8kZYHxbvhLrsDMSzWTKrWJBE6B7NYSxSh' // Testnet - v2 (protocol v12: count trees + YAPP token + tokenCost; pricing rule + repost postOwner index) + +// YAPP token (defined at position 0 of the v2 social contract) +export const YAPP_TOKEN_POSITION = 0 +// Contract-owner / token authority identity (signs freeze/unfreeze/slash on testnet). +// Mainnet TODO: move moderation to a Group and drop this single-identity gate. +export const YAPP_TOKEN_AUTHORITY_ID = 'hbGEcFcXKJ2W9Di24ekiozTWfFszrjqkxbjfEep3D8A' +// Per-action token cost baked into the v2 contract's tokenCost.create (immutable ratio). +// Used as the `maximumTokenCost` guard when creating token-paid documents. +export const YAPP_TOKEN_COSTS = { + post: 10, + reply: 3, + like: 1, + repost: 1, +} as const export const YAPPR_PROFILE_CONTRACT_ID = 'FZSnZdKsLAuWxE7iZJq12eEz6xfGTgKPxK7uZJapTQxe' // Unified profile contract export const YAPPR_DM_CONTRACT_ID = 'J7MP9YU1aEGNAe7bjB45XdrjDLBsevFLPK1t1YwFS4ck' // Testnet - DM contract v3 (simplified readReceipt) // YAPPR_BLOCK_CONTRACT_ID removed - block, blockFilter, blockFollow document types now in YAPPR_CONTRACT_ID @@ -23,6 +37,10 @@ export const BLOG_POST_SIZE_LIMIT = 16384 // Max total compressed content (lea // App URL (custom domain on GitHub Pages) export const APP_URL = 'https://yap.pr' +// Previous ("v2") Yappr deployment — the old contract's content lives here. +// Linked from empty / end-of-feed states so users can still reach pre-cutover posts. +export const LEGACY_APP_URL = 'https://yappr-v2.thepasta.org' + // Network configuration export const DEFAULT_NETWORK = 'testnet' diff --git a/lib/dash-platform-client.ts b/lib/dash-platform-client.ts index 02c5401c..e2d14e32 100644 --- a/lib/dash-platform-client.ts +++ b/lib/dash-platform-client.ts @@ -72,7 +72,6 @@ export class DashPlatformClient { replyToPostOwnerId?: string quotedPostId?: string mediaUrl?: string - primaryHashtag?: string }) { // Get identity ID from instance or auth context let identityId = this.identityId @@ -114,7 +113,6 @@ export class DashPlatformClient { const post = await postService.createPost(identityId, content.trim(), { quotedPostId: options?.quotedPostId, mediaUrl: options?.mediaUrl, - primaryHashtag: options?.primaryHashtag?.replace('#', ''), language: 'en' }) diff --git a/lib/error-utils.ts b/lib/error-utils.ts index 6a41096a..8984ebd2 100644 --- a/lib/error-utils.ts +++ b/lib/error-utils.ts @@ -79,10 +79,32 @@ export function isNonFatalWaitError(error: unknown): boolean { return msg.includes('unknown contract') } +/** + * Checks if an error indicates the signer lacks enough YAPP tokens to pay a + * document's tokenCost (post/reply/like/repost). When true, the UI should + * prompt the user to buy YAPP rather than show a generic failure. + */ +export function isInsufficientTokenError(error: unknown): boolean { + const msg = extractErrorMessage(error).toLowerCase() + return ( + msg.includes('identitydoesnothaveenoughtokenbalance') || + msg.includes('not have enough token') || + msg.includes('enough token balance') || + // Drive phrasing: "Identity X does not have enough balance for token Y: + // required 10, actual 0, action: Document create token payment" + msg.includes('enough balance for token') || + msg.includes('insufficient token') + ) +} + /** * Categorizes common Dash Platform errors and returns a user-friendly message. */ export function categorizeError(error: unknown): string { + if (isInsufficientTokenError(error)) { + return 'You don\'t have enough YAPP. Buy more to keep posting.' + } + const errorMessage = extractErrorMessage(error) if ( diff --git a/lib/services/bookmark-service.ts b/lib/services/bookmark-service.ts index a01a0dee..88269f16 100644 --- a/lib/services/bookmark-service.ts +++ b/lib/services/bookmark-service.ts @@ -2,7 +2,7 @@ import { logger } from '@/lib/logger'; import { BaseDocumentService } from './document-service'; import { stateTransitionService } from './state-transition-service'; import { transformDocumentWithField, identifierStringToDocumentBytes } from './sdk-helpers'; -import { paginateFetchAll } from './pagination-utils'; +import { paginateFetchAll, chunk, mapLimit, MAX_IN_CLAUSE_VALUES } from './pagination-utils'; export interface BookmarkDocument { $id: string; @@ -151,16 +151,20 @@ class BookmarkService extends BaseDocumentService { if (postIds.length === 0) return []; try { - const result = await this.query({ - where: [ - ['$ownerId', '==', userId], - ['postId', 'in', postIds] - ], - orderBy: [['$ownerId', 'asc'], ['postId', 'asc']], - limit: postIds.length - }); + // Platform caps `in` clauses (and query limits) at 100 values, so + // oversized pages must be batched. + const batches = await mapLimit(chunk(postIds, MAX_IN_CLAUSE_VALUES), 2, (batch) => + this.query({ + where: [ + ['$ownerId', '==', userId], + ['postId', 'in', batch] + ], + orderBy: [['$ownerId', 'asc'], ['postId', 'asc']], + limit: batch.length + }) + ); - return result.documents; + return batches.flatMap((result) => result.documents); } catch (error) { logger.error('Error getting user bookmarks for posts:', error); return []; diff --git a/lib/services/follow-service.ts b/lib/services/follow-service.ts index 06f2a4b6..0322a850 100644 --- a/lib/services/follow-service.ts +++ b/lib/services/follow-service.ts @@ -3,7 +3,7 @@ import { BaseDocumentService } from './document-service'; import { stateTransitionService } from './state-transition-service'; import { identifierStringToDocumentBytes, RequestDeduplicator, transformDocumentWithField } from './sdk-helpers'; import { getEvoSdk } from './evo-sdk-service'; -import { paginateCount, paginateFetchAll } from './pagination-utils'; +import { paginateFetchAll, documentCount } from './pagination-utils'; export interface FollowDocument { $id: string; @@ -231,21 +231,12 @@ class FollowService extends BaseDocumentService { try { const sdk = await getEvoSdk(); - const { count } = await paginateCount( - sdk, - () => ({ - dataContractId: this.contractId, - documentTypeName: 'follow', - where: [ - ['followingId', '==', userId], - ['$createdAt', '>', 0] - ], - // Use followers index: [followingId, $createdAt] - orderBy: [['followingId', 'asc'], ['$createdAt', 'asc']] - }) - ); - - return count; + // O(1) count tree on the `followerCount` index [followingId]. + return await documentCount(sdk, { + dataContractId: this.contractId, + documentTypeName: 'follow', + where: [['followingId', '==', userId]], + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error counting followers:', errorMessage, error); @@ -264,21 +255,12 @@ class FollowService extends BaseDocumentService { try { const sdk = await getEvoSdk(); - const { count } = await paginateCount( - sdk, - () => ({ - dataContractId: this.contractId, - documentTypeName: 'follow', - where: [ - ['$ownerId', '==', userId], - ['$createdAt', '>', 0] - ], - // Use following index: [$ownerId, $createdAt] - orderBy: [['$ownerId', 'asc'], ['$createdAt', 'asc']] - }) - ); - - return count; + // O(1) count tree on the `followingCount` index [$ownerId]. + return await documentCount(sdk, { + dataContractId: this.contractId, + documentTypeName: 'follow', + where: [['$ownerId', '==', userId]], + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error counting following:', errorMessage, error); diff --git a/lib/services/identity-service.ts b/lib/services/identity-service.ts index 74b150b6..df2b23c1 100644 --- a/lib/services/identity-service.ts +++ b/lib/services/identity-service.ts @@ -235,8 +235,10 @@ class IdentityService { return balanceInfo; } catch (error) { logger.error('Error fetching balance:', error); - // Return zero balance on error - return { confirmed: 0, total: 0 }; + // Rethrow so callers can tell "balance unknown" from a real zero — + // returning 0 here made transient DAPI failures block valid purchases + // ("Not enough DASH credits") and report "You have 0 DASH" on tips. + throw error; } } diff --git a/lib/services/like-service.ts b/lib/services/like-service.ts index 4d533a6d..b257114d 100644 --- a/lib/services/like-service.ts +++ b/lib/services/like-service.ts @@ -2,7 +2,8 @@ import { logger } from '@/lib/logger'; import { BaseDocumentService } from './document-service'; import { stateTransitionService } from './state-transition-service'; import { identifierStringToDocumentBytes, normalizeSDKResponse, identifierToBase58 } from './sdk-helpers'; -import { paginateFetchAll } from './pagination-utils'; +import { paginateFetchAll, documentCount, groupedDocumentCount, chunk, mapLimit, MAX_IN_CLAUSE_VALUES } from './pagination-utils'; +import { isInsufficientTokenError } from '../error-utils'; export interface LikeDocument { $id: string; @@ -73,9 +74,14 @@ class LikeService extends BaseDocumentService { documentData ); - return result.success; + if (!result.success) { + throw new Error(result.error || 'Like failed'); + } + return true; } catch (error) { logger.error('Error liking post:', error); + // Let the UI prompt to buy YAPP on insufficient-token failures. + if (isInsufficientTokenError(error)) throw error; return false; } } @@ -171,10 +177,64 @@ class LikeService extends BaseDocumentService { /** * Count likes for a post */ + /** + * Which of the given posts the user has liked — queries only the user's OWN + * likes via the unique `postAndOwner` [postId, $ownerId] index, so the result + * is bounded by the number of posts (not total likes) and never undercounts. + */ + async getUserLikedPostIds(userId: string, postIds: string[]): Promise> { + if (postIds.length === 0) return new Set(); + try { + const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); + const liked = new Set(); + // Platform caps `in` clauses at 100 values, so oversized pages (e.g. a + // profile's merged posts + reposts) must be batched. The unique + // [postId, $ownerId] index yields at most one like per postId, so + // `limit: batch.length` can never truncate a batch's results. + await mapLimit(chunk(postIds, MAX_IN_CLAUSE_VALUES), 2, async (batch) => { + const response = await sdk.documents.query({ + dataContractId: this.contractId, + documentTypeName: 'like', + where: [['postId', 'in', batch], ['$ownerId', '==', userId]], + orderBy: [['postId', 'asc'], ['$ownerId', 'asc']], + limit: batch.length, + }); + for (const doc of normalizeSDKResponse(response)) { + const like = this.transformDocument(doc); + if (like?.postId) liked.add(like.postId); + } + }); + return liked; + } catch (error) { + logger.error('Error fetching user liked post ids:', error); + return new Set(); + } + } + async countLikes(postId: string): Promise { - // In a real implementation, this would be more efficient - const likes = await this.getPostLikes(postId); - return likes.length; + try { + const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); + // O(1) count tree on the `byPost` index [postId]. + return await documentCount(sdk, { + dataContractId: this.contractId, + documentTypeName: 'like', + where: [['postId', '==', postId]], + }); + } catch (error) { + logger.error('Error counting likes:', error); + return 0; + } + } + + /** Like counts for multiple posts via one grouped count-tree query (falls back to per-post reads). */ + async countLikesForPosts(postIds: string[]): Promise> { + const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); + return groupedDocumentCount( + sdk, + { dataContractId: this.contractId, documentTypeName: 'like', groupField: 'postId' }, + postIds, + (id) => this.countLikes(id) + ); } /** diff --git a/lib/services/pagination-utils.ts b/lib/services/pagination-utils.ts index 76efd6db..90f98aad 100644 --- a/lib/services/pagination-utils.ts +++ b/lib/services/pagination-utils.ts @@ -9,7 +9,8 @@ * for both counting and fetching complete lists. */ -import { normalizeSDKResponse } from './sdk-helpers'; +import { logger } from '@/lib/logger'; +import { normalizeSDKResponse, identifierToHex } from './sdk-helpers'; export interface PaginateOptions { /** Maximum results to return (safety limit). Default: 1000 */ @@ -34,6 +35,151 @@ export interface PaginateFetchResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any type SDK = any; +/** + * Dash Platform caps `in` clauses (and per-query limits) at 100 values — + * queries over larger id lists must be split into batches of at most this size. + */ +export const MAX_IN_CLAUSE_VALUES = 100; + +/** Split items into consecutive batches of at most `size` items. */ +export function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +/** + * Map over items with bounded concurrency (a tiny promise pool). Prevents a + * whole feed page from firing an unbounded burst of DAPI requests at once. + */ +export async function mapLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + }; + await Promise.all( + Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker) + ); + return results; +} + +/** + * Count documents matching a query using the Platform count-tree (O(1)). + * + * Requires the queried document type to declare a `countable` index covering the + * `where` clause (see yappr-social-contract-v2). The query must NOT include a + * `limit`/`startAfter` — counts are over the whole matching set. + * + * Returns the grand total (the `''` key of the SDK's grouped count map). For + * grouped counts (e.g. `groupBy: ['postId']` with `postId in [...]`), call + * `sdk.documents.count` directly and read per-key entries. + * + * @example + * const likes = await documentCount(sdk, { + * dataContractId: contractId, + * documentTypeName: 'like', + * where: [['postId', '==', postId]], + * }); + */ +export async function documentCount( + sdk: SDK, + query: Record +): Promise { + const result = await sdk.documents.count(query); + // SDK returns Map; '' is the grand total when no groupBy is set. + const total = result instanceof Map ? result.get('') : result?.['']; // tolerate plain-object shape + if (total === undefined || total === null) { + // Shouldn't happen for a countable index; log so a silent 0 doesn't hide a + // response-shape mismatch or a doctype/index that isn't actually countable. + logger.warn('documentCount: no grand-total key in count result; treating as 0', { + documentTypeName: (query as { documentTypeName?: string }).documentTypeName, + }); + return 0; + } + return Number(total); +} + +/** + * Batch-count documents grouped by an identifier `In`-field's count-tree, in one + * DAPI round-trip instead of one `documentCount` call per id. + * + * Requires the queried document type to declare a `countable` index whose sole + * property is `groupField` (e.g. `byPost`/`byParent` in yappr-social-contract-v2). + * `ids` are base58 identifier strings; the returned map is keyed the same way. + * + * The SDK's raw grouped-count map is keyed by hex-encoded property bytes — an + * encoding not otherwise exercised by this app, so on any response that doesn't + * decode against our expected hex keys, this transparently falls back to + * `fallbackCount` per id (bounded concurrency) rather than risk silently + * reporting every id as a 0 count. + */ +export async function groupedDocumentCount( + sdk: SDK, + query: { dataContractId: unknown; documentTypeName: string; groupField: string }, + ids: string[], + fallbackCount: (id: string) => Promise +): Promise> { + const result = new Map(); + if (ids.length === 0) return result; + ids.forEach((id) => result.set(id, 0)); + + // Platform caps `in` clauses at 100 values, so oversized id lists (e.g. a + // profile page's merged posts + reposts) must be batched or the query errors. + await mapLimit(chunk(ids, MAX_IN_CLAUSE_VALUES), 2, async (batch) => { + const hexToId = new Map(); + batch.forEach((id) => { + const hex = identifierToHex(id); + if (hex) hexToId.set(hex, id); + }); + + try { + const raw: unknown = await sdk.documents.count({ + dataContractId: query.dataContractId, + documentTypeName: query.documentTypeName, + where: [[query.groupField, 'in', batch]], + groupBy: [query.groupField], + limit: batch.length, + }); + + const entries: [string, unknown][] = + raw instanceof Map ? Array.from(raw.entries()) : Object.entries((raw ?? {}) as Record); + + let matched = 0; + for (const [key, value] of entries) { + if (key === '') continue; // aggregate-mode key; shouldn't appear once groupBy is set + const id = hexToId.get(key); + if (id) { + result.set(id, Number(value as bigint | number)); + matched++; + } + } + + if (entries.length > 0 && matched === 0) { + throw new Error('groupedDocumentCount: no group keys matched expected hex ids'); + } + } catch (error) { + logger.warn('groupedDocumentCount: falling back to per-id counts', { + documentTypeName: query.documentTypeName, + error: error instanceof Error ? error.message : String(error), + }); + const counts = await mapLimit(batch, 6, fallbackCount); + batch.forEach((id, i) => result.set(id, counts[i])); + } + }); + + return result; +} + /** * Paginate through all documents matching a query and return the count. * Used for count methods that need accurate totals. diff --git a/lib/services/post-service.ts b/lib/services/post-service.ts index 4acc3c87..bc5c9d79 100644 --- a/lib/services/post-service.ts +++ b/lib/services/post-service.ts @@ -17,8 +17,6 @@ export interface PostDocument { mediaUrl?: string; quotedPostId?: string; quotedPostOwnerId?: string; - firstMentionId?: string; - primaryHashtag?: string; language?: string; sensitive?: boolean; // Private feed fields @@ -206,8 +204,6 @@ class PostService extends BaseDocumentService { mediaUrl?: string; quotedPostId?: string; quotedPostOwnerId?: string; - firstMentionId?: string; - primaryHashtag?: string; language?: string; sensitive?: boolean; /** Encryption options for private posts */ @@ -261,8 +257,6 @@ class PostService extends BaseDocumentService { if (options.mediaUrl) data.mediaUrl = options.mediaUrl; if (options.quotedPostId) data.quotedPostId = identifierStringToDocumentBytes(options.quotedPostId); if (options.quotedPostOwnerId) data.quotedPostOwnerId = identifierStringToDocumentBytes(options.quotedPostOwnerId); - if (options.firstMentionId) data.firstMentionId = options.firstMentionId; - if (options.primaryHashtag) data.primaryHashtag = options.primaryHashtag; if (options.sensitive !== undefined) data.sensitive = options.sensitive; return this.create(ownerId, data); @@ -440,20 +434,6 @@ class PostService extends BaseDocumentService { }); } - /** - * Get posts by hashtag - */ - async getPostsByHashtag(hashtag: string, options: QueryOptions = {}): Promise> { - const queryOptions: QueryOptions = { - where: [['primaryHashtag', '==', hashtag.replace('#', '')]], - orderBy: [['$createdAt', 'desc']], - limit: 20, - ...options - }; - - return this.query(queryOptions); - } - /** * Get post statistics (likes, reposts, replies) */ diff --git a/lib/services/post-stats-helpers.ts b/lib/services/post-stats-helpers.ts index c058697a..1c04f848 100644 --- a/lib/services/post-stats-helpers.ts +++ b/lib/services/post-stats-helpers.ts @@ -109,18 +109,15 @@ export async function fetchBatchUserInteractions( import('./bookmark-service'), ]); - const [allLikesForPosts, allRepostsForPosts, userBookmarks] = await Promise.all([ - likeService.getLikesByPostIds(postIds), - repostService.getRepostsByPostIds(postIds), + // Query only the CURRENT user's own likes/reposts (bounded by page size via + // the composite indexes) instead of fetching all users' likes capped at 100 + // and filtering client-side — which could miss the user's own on busy pages. + const [likedPostIds, repostedPostIds, userBookmarks] = await Promise.all([ + likeService.getUserLikedPostIds(currentUserId, postIds), + repostService.getUserRepostedPostIds(currentUserId, postIds), bookmarkService.getUserBookmarksForPosts(currentUserId, postIds), ]); - const likedPostIds = new Set( - allLikesForPosts.filter((like) => like.$ownerId === currentUserId).map((like) => like.postId) - ); - const repostedPostIds = new Set( - allRepostsForPosts.filter((repost) => repost.$ownerId === currentUserId).map((repost) => repost.postId) - ); const bookmarkedPostIds = new Set(userBookmarks.map((bookmark) => bookmark.postId)); postIds.forEach((postId) => { @@ -155,25 +152,23 @@ export async function fetchBatchPostStats(postIds: string[]): Promise { - const stats = result.get(postId); - if (stats) stats.replies = count; + postIds.forEach((id) => { + const stats = result.get(id); + if (stats) { + stats.likes = likeCounts.get(id) ?? 0; + stats.reposts = repostCounts.get(id) ?? 0; + stats.replies = replyCounts.get(id) ?? 0; + } }); } catch (error) { logger.error('Error getting batch post stats:', error); diff --git a/lib/services/reply-service.ts b/lib/services/reply-service.ts index 7fa78ad8..54bdca22 100644 --- a/lib/services/reply-service.ts +++ b/lib/services/reply-service.ts @@ -3,8 +3,10 @@ import { BaseDocumentService, QueryOptions, DocumentResult } from './document-se import { Reply, PostQueryOptions } from '../../types'; import { dpnsService } from './dpns-service'; import { unifiedProfileService } from './unified-profile-service'; -import { identifierToBase58, normalizeSDKResponse, RequestDeduplicator, identifierStringToDocumentBytes, normalizeBytes, createDefaultUser } from './sdk-helpers'; +import { identifierToBase58, normalizeSDKResponse, identifierStringToDocumentBytes, normalizeBytes, createDefaultUser } from './sdk-helpers'; import type { EncryptionOptions } from './post-service'; +import { getEvoSdk } from './evo-sdk-service'; +import { documentCount, groupedDocumentCount } from './pagination-utils'; export interface ReplyDocument { $id: string; @@ -32,8 +34,6 @@ export interface EncryptionSource { } class ReplyService extends BaseDocumentService { - // Request deduplicators for batch operations - private repliesDeduplicator = new RequestDeduplicator>(); constructor() { super('reply'); @@ -351,67 +351,27 @@ class ReplyService extends BaseDocumentService { */ async countReplies(parentId: string): Promise { try { - const result = await this.query({ - where: [ - ['parentId', '==', parentId], - ['$createdAt', '>', 0] - ], - orderBy: [['parentId', 'asc'], ['$createdAt', 'asc']], - limit: 100 - }); - return result.documents.length; - } catch { - return 0; - } - } - - /** - * Batch count replies for multiple posts. - * Deduplicates in-flight requests. - */ - async countRepliesByParentIds(parentIds: string[]): Promise> { - if (parentIds.length === 0) { - return new Map(); - } - - const cacheKey = RequestDeduplicator.createBatchKey(parentIds); - return this.repliesDeduplicator.dedupe(cacheKey, () => this.fetchRepliesByParentIds(parentIds)); - } - - /** Internal: Actually fetch reply counts */ - private async fetchRepliesByParentIds(parentIds: string[]): Promise> { - const result = new Map(); - parentIds.forEach(id => result.set(id, 0)); - - try { - const { getEvoSdk } = await import('./evo-sdk-service'); const sdk = await getEvoSdk(); - - const response = await sdk.documents.query({ + // O(1) count tree on the `byParent` index [parentId] (also removes the old 100-cap undercount). + return await documentCount(sdk, { dataContractId: this.contractId, documentTypeName: 'reply', - where: [['parentId', 'in', parentIds]], - orderBy: [['parentId', 'asc']], - limit: 100 + where: [['parentId', '==', parentId]], }); - - const documents = normalizeSDKResponse(response); - - // Count replies per parent - for (const doc of documents) { - const data = (doc.data || doc) as Record; - const rawParentId = data.parentId || doc.parentId; - const parentId = rawParentId ? identifierToBase58(rawParentId) : null; - - if (parentId && result.has(parentId)) { - result.set(parentId, (result.get(parentId) || 0) + 1); - } - } - } catch (error) { - logger.error('Error getting replies batch:', error); + } catch { + return 0; } + } - return result; + /** Reply counts for multiple posts via one grouped count-tree query (falls back to per-post reads). */ + async countRepliesForPosts(parentIds: string[]): Promise> { + const sdk = await getEvoSdk(); + return groupedDocumentCount( + sdk, + { dataContractId: this.contractId, documentTypeName: 'reply', groupField: 'parentId' }, + parentIds, + (id) => this.countReplies(id) + ); } /** diff --git a/lib/services/repost-service.ts b/lib/services/repost-service.ts index 4fc40e27..455f185b 100644 --- a/lib/services/repost-service.ts +++ b/lib/services/repost-service.ts @@ -2,115 +2,85 @@ import { logger } from '@/lib/logger'; import { YAPPR_CONTRACT_ID } from '../constants'; import { stateTransitionService } from './state-transition-service'; import { identifierStringToDocumentBytes, normalizeSDKResponse, identifierToBase58 } from './sdk-helpers'; -import { paginateFetchAll } from './pagination-utils'; +import { paginateFetchAll, documentCount, groupedDocumentCount, chunk, mapLimit, MAX_IN_CLAUSE_VALUES } from './pagination-utils'; +import { isInsufficientTokenError } from '../error-utils'; -/** - * Repost document - now stored as a post with quotedPostId and empty content. - * This interface represents the repost data for compatibility with existing code. - */ +/** A repost of a post — a dedicated `repost` document ({ postId, postOwnerId }). */ export interface RepostDocument { $id: string; $ownerId: string; $createdAt: number; - postId: string; // The quotedPostId (the post being reposted) - postOwnerId?: string; // The quotedPostOwnerId + postId: string; // The post being reposted + postOwnerId?: string; // Owner of the reposted post } /** - * Repost Service - * - * Reposts are now stored as post documents with: - * - quotedPostId: ID of the post being reposted - * - quotedPostOwnerId: Owner of the quoted post (for notifications) - * - content: Empty string (distinguishes pure repost from quote tweet) - * - language: Required (use 'en' as default if not specified) + * Repost Service — reposts are dedicated `repost` documents (tokenCost 1) with a + * `byPost` count tree, an `ownerAndPost` unique index, and `postOwnerAndTime` for + * "X reposted your post" notifications. */ class RepostService { private contractId = YAPPR_CONTRACT_ID; - private documentType = 'post'; - - /** - * Transform a post document into RepostDocument format. - * Only transforms posts that are reposts (have quotedPostId + empty content). - */ - private transformToRepostDocument(doc: Record): RepostDocument | null { - const data = (doc.data || doc) as Record; + private documentType = 'repost'; - // Check if this is a repost (has quotedPostId) - const rawQuotedPostId = data.quotedPostId || doc.quotedPostId; - if (!rawQuotedPostId) return null; - - // Check if content is empty (pure repost vs quote tweet) - const content = (data.content || doc.content || '') as string; - if (content && content.trim() !== '') return null; + private async sdk() { + return import('../services/evo-sdk-service').then(m => m.getEvoSdk()); + } - const quotedPostId = identifierToBase58(rawQuotedPostId); - if (!quotedPostId) { - logger.error('RepostService: Invalid quotedPostId format:', rawQuotedPostId); + private map(doc: Record): RepostDocument | null { + const data = (doc.data || doc) as Record; + const rawPostId = data.postId || doc.postId; + const postId = rawPostId ? identifierToBase58(rawPostId) : null; + if (!postId) { + logger.error('RepostService: repost doc has invalid/missing postId:', rawPostId); return null; } - - // Convert quotedPostOwnerId - const rawQuotedPostOwnerId = data.quotedPostOwnerId || doc.quotedPostOwnerId; - const quotedPostOwnerId = rawQuotedPostOwnerId ? identifierToBase58(rawQuotedPostOwnerId) : undefined; - + const rawOwner = data.postOwnerId || doc.postOwnerId; return { $id: (doc.$id || doc.id) as string, $ownerId: (doc.$ownerId || doc.ownerId) as string, $createdAt: (doc.$createdAt || doc.createdAt) as number, - postId: quotedPostId, - postOwnerId: quotedPostOwnerId || undefined, + postId, + postOwnerId: rawOwner ? identifierToBase58(rawOwner) || undefined : undefined, }; } /** - * Repost a post. - * Creates a post document with empty content and quotedPostId. - * @param postId - ID of the post being reposted - * @param ownerId - Identity ID of the user reposting - * @param postOwnerId - Identity ID of the post author (for efficient notification queries) - * @param language - Language code (defaults to 'en') + * Repost a post — creates a `repost` document (tokenCost 1). + * @param postId - the post being reposted + * @param ownerId - the reposting user + * @param postOwnerId - the reposted post's author (required; for notifications) */ - async repostPost(postId: string, ownerId: string, postOwnerId?: string, language: string = 'en'): Promise { + async repostPost(postId: string, ownerId: string, postOwnerId: string): Promise { try { - // Check if already reposted const existing = await this.getRepost(postId, ownerId); if (existing) { logger.info('Post already reposted'); return true; } - // Build document data - a post with empty content + quotedPostId - const documentData: Record = { - content: '', // Empty content marks this as a pure repost - quotedPostId: identifierStringToDocumentBytes(postId), - language: language, // Required field - }; - - // Add quotedPostOwnerId if provided (for notification queries) - if (postOwnerId) { - documentData.quotedPostOwnerId = identifierStringToDocumentBytes(postOwnerId); - } - - // Use state transition service for creation const result = await stateTransitionService.createDocument( this.contractId, this.documentType, ownerId, - documentData + { + postId: identifierStringToDocumentBytes(postId), + postOwnerId: identifierStringToDocumentBytes(postOwnerId), + } ); - return result.success; + if (!result.success) { + throw new Error(result.error || 'Repost failed'); + } + return true; } catch (error) { logger.error('Error reposting:', error); + // Let the UI prompt to buy YAPP on insufficient-token failures. + if (isInsufficientTokenError(error)) throw error; return false; } } - /** - * Remove repost. - * Finds and deletes the post document that represents the repost. - */ async removeRepost(postId: string, ownerId: string): Promise { try { const repost = await this.getRepost(postId, ownerId); @@ -118,15 +88,12 @@ class RepostService { logger.info('Post not reposted'); return true; } - - // Use state transition service for deletion const result = await stateTransitionService.deleteDocument( this.contractId, this.documentType, repost.$id, ownerId ); - return result.success; } catch (error) { logger.error('Error removing repost:', error); @@ -134,43 +101,25 @@ class RepostService { } } - /** - * Check if post is reposted by user. - * Uses the quotedPostAndOwner index. - */ async isReposted(postId: string, ownerId: string): Promise { - const repost = await this.getRepost(postId, ownerId); - return repost !== null; + return (await this.getRepost(postId, ownerId)) !== null; } - /** - * Get repost by post and owner. - * Queries the quotedPostAndOwner index. - */ + /** A user's repost of a post — `ownerAndPost` index [$ownerId, postId]. */ async getRepost(postId: string, ownerId: string): Promise { try { - const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); - - // Use 'in' pattern - '==' fails on byte array fields + const sdk = await this.sdk(); const response = await sdk.documents.query({ dataContractId: this.contractId, - documentTypeName: 'post', - where: [ - ['quotedPostId', 'in', [postId]], - ['$ownerId', '==', ownerId] - ], - orderBy: [['quotedPostId', 'asc'], ['$ownerId', 'asc']], - limit: 1 + documentTypeName: this.documentType, + where: [['$ownerId', '==', ownerId], ['postId', 'in', [postId]]], + orderBy: [['$ownerId', 'asc'], ['postId', 'asc']], + limit: 1, }); - - const documents = normalizeSDKResponse(response); - - // Filter for pure reposts (empty content) and transform - for (const doc of documents) { - const repost = this.transformToRepostDocument(doc); + for (const doc of normalizeSDKResponse(response)) { + const repost = this.map(doc); if (repost) return repost; } - return null; } catch (error) { logger.error('Error getting repost:', error); @@ -178,28 +127,20 @@ class RepostService { } } - /** - * Get reposts for a post. - * Uses the quotedPostAndOwner index, filters for empty content. - * Paginates through all results to return complete list. - */ + /** All reposts of a post — `byPost` index [postId]. */ async getPostReposts(postId: string): Promise { try { - const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); - - // Use 'in' with single-element array - '==' fails on byte array fields + const sdk = await this.sdk(); const { documents } = await paginateFetchAll( sdk, () => ({ dataContractId: this.contractId, - documentTypeName: 'post', - where: [['quotedPostId', 'in', [postId]]], - orderBy: [['quotedPostId', 'asc']] + documentTypeName: this.documentType, + where: [['postId', 'in', [postId]]], + orderBy: [['postId', 'asc']], }), - (doc) => this.transformToRepostDocument(doc) + (doc) => this.map(doc) ); - - // Filter out nulls (quote tweets with content) return documents.filter((d): d is RepostDocument => d !== null); } catch (error) { logger.error('Error getting post reposts:', error); @@ -207,30 +148,20 @@ class RepostService { } } - /** - * Get user's reposts. - * Uses the ownerAndTime index, filters for posts with quotedPostId and empty content. - * Paginates through all results to return complete list. - */ + /** A user's reposts — `ownerAndTime` index [$ownerId, $createdAt]. */ async getUserReposts(userId: string): Promise { try { - const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); - + const sdk = await this.sdk(); const { documents } = await paginateFetchAll( sdk, () => ({ dataContractId: this.contractId, - documentTypeName: 'post', - where: [ - ['$ownerId', '==', userId], - ['$createdAt', '>', 0] - ], - orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']] + documentTypeName: this.documentType, + where: [['$ownerId', '==', userId], ['$createdAt', '>', 0]], + orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']], }), - (doc) => this.transformToRepostDocument(doc) + (doc) => this.map(doc) ); - - // Filter out nulls (posts that aren't reposts or are quote tweets) return documents.filter((d): d is RepostDocument => d !== null); } catch (error) { logger.error('Error getting user reposts:', error); @@ -239,48 +170,82 @@ class RepostService { } /** - * Count reposts for a post + * Which of the given posts the user has reposted — queries only the user's OWN + * reposts via the unique `ownerAndPost` [$ownerId, postId] index, so the result + * is bounded by the number of posts (not total reposts). */ + async getUserRepostedPostIds(userId: string, postIds: string[]): Promise> { + if (postIds.length === 0) return new Set(); + try { + const sdk = await this.sdk(); + const out = new Set(); + // Platform caps `in` clauses at 100 values, so oversized pages must be + // batched. The unique [$ownerId, postId] index yields at most one repost + // per postId, so `limit: batch.length` can never truncate a batch. + await mapLimit(chunk(postIds, MAX_IN_CLAUSE_VALUES), 2, async (batch) => { + const response = await sdk.documents.query({ + dataContractId: this.contractId, + documentTypeName: this.documentType, + where: [['$ownerId', '==', userId], ['postId', 'in', batch]], + orderBy: [['$ownerId', 'asc'], ['postId', 'asc']], + limit: batch.length, + }); + for (const doc of normalizeSDKResponse(response)) { + const r = this.map(doc); + if (r) out.add(r.postId); + } + }); + return out; + } catch (error) { + logger.error('Error fetching user reposted post ids:', error); + return new Set(); + } + } + + /** Count reposts of a post — O(1) `byPost` count tree. */ async countReposts(postId: string): Promise { - const reposts = await this.getPostReposts(postId); - return reposts.length; + try { + const sdk = await this.sdk(); + return await documentCount(sdk, { + dataContractId: this.contractId, + documentTypeName: this.documentType, + where: [['postId', '==', postId]], + }); + } catch (error) { + logger.error('Error counting reposts:', error); + return 0; + } } - /** - * Get reposts for multiple posts in a single batch query. - * Uses 'in' operator for efficient querying. - * - * TODO: This query uses 'in' clause which doesn't support reliable pagination. - * The SDK returns incomplete results when subtrees are empty but still count against the limit. - * Once SDK provides better 'in' query support (e.g., a flag indicating result completeness), - * implement pagination here to handle cases where results exceed the limit. - */ + /** Repost counts for multiple posts via one grouped count-tree query (falls back to per-post reads). */ + async countRepostsForPosts(postIds: string[]): Promise> { + const sdk = await this.sdk(); + return groupedDocumentCount( + sdk, + { dataContractId: this.contractId, documentTypeName: this.documentType, groupField: 'postId' }, + postIds, + (id) => this.countReposts(id) + ); + } + + /** Batch reposts for many posts — `byPost` index, single query. */ async getRepostsByPostIds(postIds: string[]): Promise { if (postIds.length === 0) return []; - try { - const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); - - // Use 'in' operator for batch query on quotedPostId - // Must include orderBy to match the quotedPostAndOwner index + const sdk = await this.sdk(); const response = await sdk.documents.query({ dataContractId: this.contractId, - documentTypeName: 'post', - where: [['quotedPostId', 'in', postIds]], - orderBy: [['quotedPostId', 'asc']], - limit: 100 + documentTypeName: this.documentType, + where: [['postId', 'in', postIds]], + orderBy: [['postId', 'asc']], + limit: 100, }); - - const documents = normalizeSDKResponse(response); - - // Transform and filter for pure reposts only - const reposts: RepostDocument[] = []; - for (const doc of documents) { - const repost = this.transformToRepostDocument(doc); - if (repost) reposts.push(repost); + const out: RepostDocument[] = []; + for (const doc of normalizeSDKResponse(response)) { + const r = this.map(doc); + if (r) out.push(r); } - - return reposts; + return out; } catch (error) { logger.error('Error getting reposts batch:', error); return []; @@ -288,40 +253,26 @@ class RepostService { } /** - * Get reposts of posts owned by a specific user (for notification queries). - * Uses the quotedPostOwnerAndTime index. - * Limited to 100 most recent reposts for notification purposes. - * @param userId - Identity ID of the post owner - * @param since - Only return reposts created after this timestamp (optional) + * Reposts of a user's posts (for "X reposted your post" notifications). + * Uses the `postOwnerAndTime` index [postOwnerId, $createdAt]. */ async getRepostsOfMyPosts(userId: string, since?: Date): Promise { + const sinceTimestamp = since?.getTime() || 0; try { - const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); - - const sinceTimestamp = since?.getTime() || 0; - + const sdk = await this.sdk(); const response = await sdk.documents.query({ dataContractId: this.contractId, - documentTypeName: 'post', - where: [ - ['quotedPostOwnerId', '==', userId], - ['$createdAt', '>', sinceTimestamp] - ], - // Match quotedPostOwnerAndTime index: [quotedPostOwnerId: asc, $createdAt: asc] - orderBy: [['quotedPostOwnerId', 'asc'], ['$createdAt', 'asc']], - limit: 100 + documentTypeName: this.documentType, + where: [['postOwnerId', '==', userId], ['$createdAt', '>', sinceTimestamp]], + orderBy: [['postOwnerId', 'asc'], ['$createdAt', 'asc']], + limit: 100, }); - - const documents = normalizeSDKResponse(response); - - // Transform and filter for pure reposts only - const reposts: RepostDocument[] = []; - for (const doc of documents) { - const repost = this.transformToRepostDocument(doc); - if (repost) reposts.push(repost); + const out: RepostDocument[] = []; + for (const doc of normalizeSDKResponse(response)) { + const r = this.map(doc); + if (r) out.push(r); } - - return reposts; + return out; } catch (error) { logger.error('Error getting reposts of my posts:', error); return []; diff --git a/lib/services/sdk-helpers.ts b/lib/services/sdk-helpers.ts index 3ac7424b..a5702a45 100644 --- a/lib/services/sdk-helpers.ts +++ b/lib/services/sdk-helpers.ts @@ -131,6 +131,18 @@ export function base58ToBytes(value: string): Uint8Array | null { } } +/** + * Decode a base58 identifier string into lowercase hex — the key format + * `sdk.documents.count`'s grouped (`groupBy`) results use for identifier + * properties (platform-value-encoded bytes, hex-encoded). Returns null if the + * value is not a valid base58 identifier. + */ +export function identifierToHex(value: string): string | null { + const bytes = base58ToBytes(value); + if (!bytes) return null; + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + /** * Typed document write helper for required identifier-like fields. * diff --git a/lib/services/state-transition-service.ts b/lib/services/state-transition-service.ts index 2629ffe8..11265173 100644 --- a/lib/services/state-transition-service.ts +++ b/lib/services/state-transition-service.ts @@ -5,6 +5,7 @@ import { documentBuilderService } from './document-builder-service'; import { findMatchingKeyIndex, getSecurityLevelName, type IdentityPublicKeyInfo } from '@/lib/crypto/keys'; import type { IdentityPublicKey as WasmIdentityPublicKey } from '@dashevo/wasm-sdk/compressed'; import { promptForAuthKey } from '../auth-utils'; +import { YAPPR_CONTRACT_ID, YAPP_TOKEN_COSTS, YAPP_TOKEN_POSITION } from '../constants'; import { extractErrorMessage, isTimeoutError, isAlreadyExistsError, isNonFatalWaitError } from '../error-utils'; import { documentToPlainObject } from './sdk-helpers'; import { @@ -14,6 +15,7 @@ import { StateTransition, PrivateKey, Identifier, + TokenPaymentInfo, } from '@dashevo/evo-sdk'; @@ -288,6 +290,21 @@ class StateTransitionService { * recognizes it's already processed (replay). No new nonce = no * double post, enforced at the protocol level. */ + /** + * Resolve the automatic token-payment agreement for a token-paid document type + * on the v2 social contract (post/reply/like/repost). Returns undefined for + * free document types or documents on other contracts. + */ + private resolveTokenPayment( + contractId: string, + documentType: string + ): { tokenContractPosition?: number; maximumTokenCost: number } | undefined { + if (contractId !== YAPPR_CONTRACT_ID) return undefined; + const amount = (YAPP_TOKEN_COSTS as Record)[documentType]; + if (!amount) return undefined; + return { maximumTokenCost: amount }; + } + async createDocument( contractId: string, documentType: string, @@ -296,6 +313,16 @@ class StateTransitionService { options?: { documentId?: string; entropy?: Uint8Array; + /** + * Token payment agreement for document types that declare a tokenCost.create + * (e.g. post/reply/like/repost). `maximumTokenCost` is the cap the user agrees + * to spend — set it to the contract's declared amount to guard against price + * changes. Token position defaults to 0 (the YAPP token). + */ + tokenPayment?: { + tokenContractPosition?: number; + maximumTokenCost: number; + }; } ): Promise { try { @@ -405,10 +432,27 @@ class StateTransitionService { const newNonce = sequenceNumber + BigInt(1); logger.info(`Nonce: current=${currentNonce}, sequence=${sequenceNumber}, using=${newNonce}`); + // Build the token payment agreement for token-paid document types + // (post/reply/like/repost on the v2 social contract). Callers may pass an + // explicit `options.tokenPayment`; otherwise we auto-attach based on the + // document type's declared tokenCost so every write path is covered. The + // signed bytes that include this are cached below, so the rebroadcast path + // above replays the same agreement verbatim. + const effectivePayment = options?.tokenPayment ?? this.resolveTokenPayment(contractId, documentType); + let tokenPaymentInfo: TokenPaymentInfo | undefined; + if (effectivePayment) { + tokenPaymentInfo = new TokenPaymentInfo({ + tokenContractPosition: effectivePayment.tokenContractPosition ?? YAPP_TOKEN_POSITION, + maximumTokenCost: BigInt(effectivePayment.maximumTokenCost), + }); + logger.info(`Attaching tokenPaymentInfo for ${documentType}: maxCost=${effectivePayment.maximumTokenCost}`); + } + // v3.1: DocumentCreateTransition takes an options object const createTransition = new DocumentCreateTransition({ document, identityContractNonce: newNonce, + ...(tokenPaymentInfo ? { tokenPaymentInfo } : {}), }); // Wrap in a BatchTransition diff --git a/lib/services/tip-service.ts b/lib/services/tip-service.ts index a2f0e8f6..4afebd92 100644 --- a/lib/services/tip-service.ts +++ b/lib/services/tip-service.ts @@ -5,6 +5,7 @@ import { signerService, KeyPurpose } from './signer-service'; import { wallet } from '@dashevo/evo-sdk'; import { TipInfo } from '../../types'; import { findMatchingKeyIndex, type IdentityPublicKeyInfo } from '@/lib/crypto/keys'; +import { isInsufficientTokenError } from '@/lib/error-utils'; import type { IdentityPublicKey as WasmIdentityPublicKey } from '@dashevo/wasm-sdk/compressed'; export interface TipResult { @@ -12,6 +13,18 @@ export interface TipResult { transactionHash?: string; error?: string; errorCode?: 'INSUFFICIENT_BALANCE' | 'SELF_TIP' | 'NETWORK_ERROR' | 'INVALID_AMOUNT' | 'INVALID_KEY'; + /** + * Whether the public "X tipped Y" announcement reply posted. False when the + * tip's credit transfer succeeded but the reply (a YAPP-costed doc) was + * rejected — e.g. the tipper holds no YAPP. Only meaningful when a postId was given. + */ + announcementPosted?: boolean; + /** + * Why the announcement reply failed when `announcementPosted` is false: + * the tipper lacked YAPP for the reply's tokenCost, or any other posting + * failure (timeout, transport, rejection). + */ + announcementError?: 'INSUFFICIENT_YAPP' | 'POST_FAILED'; } // Regex to parse tip content: tip:AMOUNT_CREDITS followed by optional message @@ -125,12 +138,19 @@ class TipService { } try { - // Check sender balance - const balance = await identityService.getBalance(senderId); - if (balance.confirmed < amountCredits) { + // Check sender balance. If the balance fetch itself fails, don't treat + // that as "0 credits" — skip the pre-check and let the transfer be the + // authority (the chain rejects underfunded transfers anyway). + let confirmedBalance: number | null = null; + try { + confirmedBalance = (await identityService.getBalance(senderId)).confirmed; + } catch (error) { + logger.warn('Could not fetch balance before tip; proceeding without pre-check:', error); + } + if (confirmedBalance !== null && confirmedBalance < amountCredits) { return { success: false, - error: `Insufficient balance. You have ${this.formatDash(this.creditsToDash(balance.confirmed))}.`, + error: `Insufficient balance. You have ${this.formatDash(this.creditsToDash(confirmedBalance))}.`, errorCode: 'INSUFFICIENT_BALANCE' }; } @@ -245,14 +265,19 @@ class TipService { // Create tip post as a reply to the tipped post (only if postId provided) // TODO: Once SDK returns transition ID, pass it for on-chain verification - if (postId) { - await this.createTipPost(senderId, postId, recipientId, amountCredits, message); - } + // The tip (credit transfer) already succeeded; the announcement reply is a + // `reply` doc with a YAPP tokenCost, so a 0-YAPP tipper's reply can fail — + // report that so the UI can tell the user rather than losing it silently. + const announcement = postId + ? await this.createTipPost(senderId, postId, recipientId, amountCredits, message) + : { posted: true as const }; return { success: true, // TODO: Return actual transaction hash once SDK exposes it - transactionHash: 'confirmed' + transactionHash: 'confirmed', + announcementPosted: announcement.posted, + announcementError: announcement.posted ? undefined : announcement.reason, }; } catch (error) { @@ -268,13 +293,15 @@ class TipService { identityService.clearCache(senderId); // Create tip post (amount is known even if confirmation timed out) - if (postId) { - await this.createTipPost(senderId, postId, recipientId, amountCredits, message); - } + const announcement = postId + ? await this.createTipPost(senderId, postId, recipientId, amountCredits, message) + : { posted: true as const }; return { success: true, - transactionHash: 'pending-confirmation' + transactionHash: 'pending-confirmation', + announcementPosted: announcement.posted, + announcementError: announcement.posted ? undefined : announcement.reason, }; } @@ -318,7 +345,7 @@ class TipService { postOwnerId: string, amountCredits: number, tipMessage?: string - ): Promise { + ): Promise<{ posted: true } | { posted: false; reason: 'INSUFFICIENT_YAPP' | 'POST_FAILED' }> { try { // Format: tip:CREDITS\nmessage (message is optional) // Amount is self-reported until SDK provides transition ID for verification @@ -331,9 +358,16 @@ class TipService { await replyService.createReply(senderId, content, postId, postOwnerId); logger.info('Tip reply created successfully'); + return { posted: true }; } catch (error) { - // Log but don't fail the tip - the credit transfer already succeeded + // Log but don't fail the tip - the credit transfer already succeeded. + // Distinguish "tipper has no YAPP for the reply's tokenCost" from any + // other posting failure so the UI doesn't misattribute the cause. logger.error('Failed to create tip post:', error); + return { + posted: false, + reason: isInsufficientTokenError(error) ? 'INSUFFICIENT_YAPP' : 'POST_FAILED', + }; } } diff --git a/lib/services/token-service.ts b/lib/services/token-service.ts new file mode 100644 index 00000000..a8603a00 --- /dev/null +++ b/lib/services/token-service.ts @@ -0,0 +1,290 @@ +import { logger } from '@/lib/logger'; +import { getEvoSdk } from './evo-sdk-service'; +import { signerService, KeyPurpose, SecurityLevel } from './signer-service'; +import { Identifier } from '@dashevo/evo-sdk'; +import { findMatchingKeyIndex, type IdentityPublicKeyInfo } from '@/lib/crypto/keys'; +import type { IdentityPublicKey as WasmIdentityPublicKey } from '@dashevo/wasm-sdk/compressed'; +import { YAPPR_CONTRACT_ID, YAPP_TOKEN_POSITION } from '../constants'; +import { extractErrorMessage } from '../error-utils'; + +export interface TokenResult { + success: boolean; + error?: string; + errorCode?: 'INVALID_KEY' | 'INSUFFICIENT_CREDITS' | 'BELOW_MINIMUM' | 'NOT_AUTHORIZED' | 'NETWORK_ERROR' | 'NEEDS_CRITICAL_KEY'; +} + +/** Minimum YAPP per direct purchase — enforced on-chain by the SetPrices tier, mirrored here for UX. */ +export const MIN_YAPP_PURCHASE = BigInt(100); + +class TokenService { + private tokenIdCache: string | null = null; + + /** Resolve (and cache) the YAPP token id from the contract + position. */ + async getTokenId(): Promise { + if (this.tokenIdCache) return this.tokenIdCache; + const sdk = await getEvoSdk(); + this.tokenIdCache = await sdk.tokens.calculateId(YAPPR_CONTRACT_ID, YAPP_TOKEN_POSITION); + return this.tokenIdCache; + } + + /** + * Look up a token entry from an SDK result keyed by token id. The evo-sdk + * facade types these Maps as keyed by wasm `Identifier`, so a plain + * `map.get(base58String)` can miss; match by string form of the key. + */ + private tokenMapGet(result: unknown, tokenId: string): V | undefined { + if (result instanceof Map) { + const direct = result.get(tokenId); + if (direct !== undefined) return direct as V; + let found: V | undefined; + (result as Map).forEach((v, k) => { + if (found !== undefined) return; + const key = typeof k === 'string' + ? k + : String((k as { base58?: () => string })?.base58?.() ?? k); + if (key === tokenId) found = v; + }); + return found; + } + return (result as Record)?.[tokenId]; + } + + /** + * Current YAPP balance (whole tokens, decimals=0) for an identity. + * Throws on a fetch failure so callers can distinguish "unknown" (e.g. a + * transient DAPI error) from a genuine zero balance — do not swallow to 0. + */ + async getBalance(identityId: string): Promise { + const sdk = await getEvoSdk(); + const tokenId = await this.getTokenId(); + const balances = await sdk.tokens.identityBalances(identityId, [tokenId]); + return this.tokenMapGet(balances, tokenId) ?? BigInt(0); + } + + /** + * Price in credits to buy one YAPP (the SetPrices tier value). Null if not for + * sale. Fetched fresh each call so `maxTotalCost` never uses a stale price. + */ + async getPricePerToken(): Promise { + try { + const sdk = await getEvoSdk(); + const tokenId = await this.getTokenId(); + const prices = await sdk.tokens.directPurchasePrices([tokenId]); + const info = this.tokenMapGet<{ currentPrice?: string }>(prices, tokenId); + if (!info) return null; + const current = info.currentPrice; + if (current === undefined) return null; + return BigInt(current); + } catch (error) { + logger.error('Error fetching YAPP price:', error); + return null; + } + } + + /** + * Buy `amount` YAPP for the buyer, spending at most `maxTotalCost` credits. + * `maxTotalCost` is the cost the user approved in the UI (caller passes the + * quoted total), so a mid-flight price increase is rejected on-chain rather + * than silently overspending. + * + * Drive only accepts a CRITICAL auth key for direct purchase (it spends + * credits). If the stored login key is HIGH, this returns + * NEEDS_CRITICAL_KEY without broadcasting; the UI should prompt for the + * CRITICAL key and retry with `criticalKeyWif` (used to sign, never stored). + */ + async buyYapp(buyerId: string, amount: bigint, maxTotalCost: bigint, criticalKeyWif?: string): Promise { + if (amount < MIN_YAPP_PURCHASE) { + return { success: false, error: `Minimum purchase is ${MIN_YAPP_PURCHASE} YAPP`, errorCode: 'BELOW_MINIMUM' }; + } + + try { + const sdk = await getEvoSdk(); + const { signer, identityKey } = await this.getAuthSigner(buyerId, { + requireCritical: true, + overrideWif: criticalKeyWif, + }); + + await sdk.tokens.directPurchase({ + dataContractId: new Identifier(YAPPR_CONTRACT_ID), + tokenPosition: YAPP_TOKEN_POSITION, + buyerId: new Identifier(buyerId), + amount, + maxTotalCost, + identityKey, + signer, + } as Parameters[0]); + + return { success: true }; + } catch (error) { + return this.toResult(error, 'Purchase failed'); + } + } + + /** + * Freeze an identity's YAPP balance (moderation — blocks posting + transfers). + * Signed by the token authority (contract owner) identity. + */ + async freeze(authorityId: string, frozenIdentityId: string, publicNote?: string): Promise { + return this.authorityAction('freeze', authorityId, frozenIdentityId, publicNote); + } + + /** Unfreeze a previously frozen identity (reinstate). */ + async unfreeze(authorityId: string, frozenIdentityId: string, publicNote?: string): Promise { + return this.authorityAction('unfreeze', authorityId, frozenIdentityId, publicNote); + } + + /** Destroy (burn) a frozen identity's YAPP balance — the slash. Requires a prior freeze. */ + async destroyFrozen(authorityId: string, frozenIdentityId: string, publicNote?: string): Promise { + return this.authorityAction('destroyFrozen', authorityId, frozenIdentityId, publicNote); + } + + private async authorityAction( + action: 'freeze' | 'unfreeze' | 'destroyFrozen', + authorityId: string, + frozenIdentityId: string, + publicNote?: string + ): Promise { + try { + const sdk = await getEvoSdk(); + const { signer, identityKey } = await this.getAuthSigner(authorityId); + + const options = { + dataContractId: new Identifier(YAPPR_CONTRACT_ID), + tokenPosition: YAPP_TOKEN_POSITION, + authorityId: new Identifier(authorityId), + frozenIdentityId: new Identifier(frozenIdentityId), + publicNote, + identityKey, + signer, + }; + + if (action === 'freeze') { + await sdk.tokens.freeze(options as Parameters[0]); + } else if (action === 'unfreeze') { + await sdk.tokens.unfreeze(options as Parameters[0]); + } else { + await sdk.tokens.destroyFrozen(options as Parameters[0]); + } + return { success: true }; + } catch (error) { + return this.toResult(error, `${action} failed`); + } + } + + /** + * Build an IdentitySigner + matching AUTHENTICATION key for an identity. + * Uses `overrideWif` when given (a key the user just entered), otherwise the + * private key from secure storage. Mirrors tip-service's transfer-key flow. + * With `requireCritical`, only a CRITICAL key may match — throws the + * NEEDS_CRITICAL_KEY marker otherwise so callers can prompt for one. + */ + private async getAuthSigner( + identityId: string, + opts?: { requireCritical?: boolean; overrideWif?: string } + ) { + let wif = opts?.overrideWif?.trim(); + if (!wif) { + const { getPrivateKey } = await import('../secure-storage'); + const stored = getPrivateKey(identityId); + if (!stored) { + const { promptForAuthKey } = await import('../auth-utils'); + promptForAuthKey(); + throw new Error('Private key not found — please re-authenticate'); + } + wif = stored.trim(); + } + + const sdk = await getEvoSdk(); + const identity = await sdk.identities.fetch(identityId); + if (!identity) throw new Error('Identity not found'); + + const allowedLevels = opts?.requireCritical + ? [SecurityLevel.CRITICAL] + : [SecurityLevel.CRITICAL, SecurityLevel.HIGH]; + const authKey = this.findMatchingAuthKey(wif, identity.publicKeys, allowedLevels); + if (!authKey) { + throw new Error( + opts?.requireCritical + ? 'critical key required' + : 'No matching AUTHENTICATION key (CRITICAL/HIGH) found for the stored private key' + ); + } + + return signerService.createSignerFromWasmKey(wif, authKey); + } + + /** Find the AUTHENTICATION key at an allowed security level that matches the provided WIF. */ + private findMatchingAuthKey( + wif: string, + wasmPublicKeys: WasmIdentityPublicKey[], + allowedLevels: number[] + ): WasmIdentityPublicKey | null { + const network = (process.env.NEXT_PUBLIC_NETWORK as 'testnet' | 'mainnet') || 'testnet'; + // Filter to the allowed levels BEFORE matching so a lower-security key + // derived from the same WIF (e.g. a MEDIUM key at a lower id after a + // rotation) can't win the match and mask a valid key. + const authKeys = wasmPublicKeys.filter(k => + !k.disabledAt && + k.purposeNumber === KeyPurpose.AUTHENTICATION && + allowedLevels.includes(k.securityLevelNumber) + ); + if (authKeys.length === 0) return null; + + const keyInfos: IdentityPublicKeyInfo[] = authKeys.map(key => ({ + id: key.keyId, + type: key.keyTypeNumber, + purpose: key.purposeNumber, + securityLevel: key.securityLevelNumber, + data: new Uint8Array(key.data.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) || []), + })); + + const match = findMatchingKeyIndex(wif, keyInfos, network); + if (!match) return null; + return authKeys.find(k => k.keyId === match.keyId) || null; + } + + private toResult(error: unknown, fallback: string): TokenResult { + const msg = extractErrorMessage(error); + logger.error(`${fallback}:`, msg); + const lower = msg.toLowerCase(); + // Local marker from getAuthSigner, or Drive's rejection ("Invalid public + // key security level HIGH. The state transition requires one of CRITICAL"). + if ( + lower.includes('critical key required') || + (lower.includes('invalid public key security level') && lower.includes('critical')) + ) { + return { + success: false, + error: 'This action needs your CRITICAL key to authorize', + errorCode: 'NEEDS_CRITICAL_KEY', + }; + } + if (lower.includes('not authorized') || lower.includes('noone')) { + return { success: false, error: 'Not authorized to perform this action', errorCode: 'NOT_AUTHORIZED' }; + } + // Insufficient Dash credits to pay for the purchase (various phrasings / + // the compact `IdentityInsufficientBalanceError` name). + if ( + (lower.includes('enough') && lower.includes('credit')) || + lower.includes('insufficientbalance') || + ((lower.includes('insufficient') || lower.includes('not enough')) && lower.includes('balance')) + ) { + return { success: false, error: 'Insufficient Dash credits to complete the purchase', errorCode: 'INSUFFICIENT_CREDITS' }; + } + if (lower.includes('underminimum') || lower.includes('under minimum') || lower.includes('minimum sale')) { + return { success: false, error: `Minimum purchase is ${MIN_YAPP_PURCHASE} YAPP`, errorCode: 'BELOW_MINIMUM' }; + } + if (lower.includes('userpricetoolow') || lower.includes('price too low') || lower.includes('price changed')) { + return { success: false, error: 'The YAPP price changed — please reopen and confirm the new cost', errorCode: 'NETWORK_ERROR' }; + } + // Only classify as a key problem when the local signer genuinely didn't match — + // NOT on transient consensus/proof errors that merely contain "key"/"signature" + // (e.g. "no quorum public key found", "signature verification for proof failed"). + if (lower.includes('no matching authentication key') || lower.includes('private key not found')) { + return { success: false, error: 'No suitable signing key for this identity', errorCode: 'INVALID_KEY' }; + } + return { success: false, error: `${fallback}: ${msg}`, errorCode: 'NETWORK_ERROR' }; + } +} + +export const tokenService = new TokenService(); diff --git a/package-lock.json b/package-lock.json index 99cd54b6..934c66b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@blocknote/core": "^0.47.0", "@blocknote/mantine": "^0.47.0", "@blocknote/react": "^0.47.0", - "@dashevo/evo-sdk": "3.1.0-dev.8", + "@dashevo/evo-sdk": "^4.0.0-rc.2", "@dicebear/collection": "^9.2.4", "@dicebear/core": "^9.2.4", "@emoji-mart/data": "^1.2.1", @@ -183,20 +183,20 @@ } }, "node_modules/@dashevo/evo-sdk": { - "version": "3.1.0-dev.8", - "resolved": "https://registry.npmjs.org/@dashevo/evo-sdk/-/evo-sdk-3.1.0-dev.8.tgz", - "integrity": "sha512-2ecyGj/4fTyH+iwk2K09xQ77LBJSYuTCmS4vFqhx65XXyXVDW/ptNe2zJw12LiDVlh3OyJykiyj1vfu9bRLt0g==", + "version": "4.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@dashevo/evo-sdk/-/evo-sdk-4.0.0-rc.2.tgz", + "integrity": "sha512-SR823jh4OE19dxNbRPMukGZXj7MTa01af0rXIW1MvO/khN5Mn9Wp4nzzPRRPGXzG74RA46bimyJAhFxqdUz0eQ==", "dependencies": { - "@dashevo/wasm-sdk": "3.1.0-dev.8" + "@dashevo/wasm-sdk": "4.0.0-rc.2" }, "engines": { "node": ">=18.18" } }, "node_modules/@dashevo/wasm-sdk": { - "version": "3.1.0-dev.8", - "resolved": "https://registry.npmjs.org/@dashevo/wasm-sdk/-/wasm-sdk-3.1.0-dev.8.tgz", - "integrity": "sha512-gOkaM4ACpvP1+2dAr6ESUG5+nNckCtFBg+OWI6EyvKkX1tkxxNuDTK/9J+2asHS7y1mTtp4axLhmnwF7UCNLeA==", + "version": "4.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@dashevo/wasm-sdk/-/wasm-sdk-4.0.0-rc.2.tgz", + "integrity": "sha512-nQH7qVcCz28ePj9TJD7NNfrqEs+YlxtSmUxnCcU8HYZ9A9i3dgW0kU9tCo4RO6mUbnEEausymfy/2rc6nXIhhQ==", "engines": { "node": ">=18.18" } diff --git a/package.json b/package.json index e61c03d2..18af0d5b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@blocknote/core": "^0.47.0", "@blocknote/mantine": "^0.47.0", "@blocknote/react": "^0.47.0", - "@dashevo/evo-sdk": "3.1.0-dev.8", + "@dashevo/evo-sdk": "^4.0.0-rc.2", "@dicebear/collection": "^9.2.4", "@dicebear/core": "^9.2.4", "@emoji-mart/data": "^1.2.1", diff --git a/scripts/set-yapp-price.mjs b/scripts/set-yapp-price.mjs new file mode 100644 index 00000000..8b10a236 --- /dev/null +++ b/scripts/set-yapp-price.mjs @@ -0,0 +1,136 @@ +/** + * One-shot: set the YAPP token's tiered direct-purchase price on the v2 contract. + * + * The JS SDK facade `sdk.tokens.setPrice` only supports a flat price, so this + * builds the TokenSetPriceForDirectPurchase transition manually with a tiered + * `SetPrices` schedule to enforce the minimum bulk buy-in (the anti-spam bond). + * + * SetPrices semantics (verified against rs-drive transformer): + * SetPrices({ "100": P }) => buying N tokens requires N >= 100 (lowest tier + * key), priced at P credits PER TOKEN (total = N * P). Buying < 100 fails with + * TokenAmountUnderMinimumSaleAmount. + * + * Run: node scripts/set-yapp-price.mjs + * Signs with the contract-owner ("contract maker") identity's HIGH auth key, + * read at runtime from the identity JSON in ~/Downloads. + */ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + EvoSDK, + TokenBaseTransition, + TokenSetPriceForDirectPurchaseTransition, + TokenPricingSchedule, + TokenTransition, + BatchedTransition, + BatchTransition, + PrivateKey, +} from '@dashevo/evo-sdk'; + +// ---- Config ----------------------------------------------------------------- +const CONTRACT_ID = 'AyBNQV6qSLY8kZYHxbvhLrsDMSzWTKrWJBE6B7NYSxSh'; +const TOKEN_POS = 0; +const MIN_PURCHASE = 100n; // lowest tier key => minimum tokens per buy +const CREDITS_PER_TOKEN = 1000000n; // P: credits per YAPP at/above the min tier +const SIGNING_KEY_ID = 2; // CRITICAL auth key (token config transitions require CRITICAL) +const IDENTITY_FILE = join(homedir(), 'Downloads', 'dash-identity-testnet-contract-maker.json'); +// ----------------------------------------------------------------------------- + +function describeErr(e) { + if (!e) return String(e); + const parts = []; + for (const k of ['message', 'name', 'code', 'cause']) if (e[k] !== undefined) parts.push(`${k}=${e[k]}`); + try { parts.push(`toString=${e.toString()}`); } catch {} + try { parts.push(`json=${JSON.stringify(e)}`); } catch {} + try { parts.push(`keys=${Object.keys(e).join(',')}`); } catch {} + return parts.join(' | '); +} + +const identityJson = JSON.parse(readFileSync(IDENTITY_FILE, 'utf8')); +const OWNER_ID = identityJson.identityId; +const signingKey = (identityJson.identityKeys || []).find((k) => k.id === SIGNING_KEY_ID); +if (!signingKey?.privateKeyWif) throw new Error(`No privateKeyWif for key id ${SIGNING_KEY_ID}`); +console.log(`owner=${OWNER_ID} signingKeyId=${SIGNING_KEY_ID} (${signingKey.securityLevel})`); + +const sdk = EvoSDK.testnetTrusted({ settings: { timeoutMs: 30000 } }); +await sdk.connect(); +const wasm = sdk.wasm; +console.log('connected'); + +// Cache the contract so the trusted SDK can verify the result proof at +// waitForResponse (otherwise: "unknown contract ... in token verification"). +await sdk.contracts.fetch(CONTRACT_ID); + +const tokenId = await sdk.tokens.calculateId(CONTRACT_ID, TOKEN_POS); +console.log('tokenId:', tokenId); + +// DIP-30 identity-contract nonce: lower 40 bits = sequence; increment that. +const SEQUENCE_MASK = (1n << 40n) - 1n; +const rawNonce = (await wasm.getIdentityContractNonce(OWNER_ID, CONTRACT_ID)) ?? 0n; +const nonce = (rawNonce & SEQUENCE_MASK) + 1n; +console.log(`nonce: raw=${rawNonce} using=${nonce}`); + +let st; +try { + const base = new TokenBaseTransition({ + identityContractNonce: nonce, + tokenContractPosition: TOKEN_POS, + dataContractId: CONTRACT_ID, + tokenId, + }); + console.log('built TokenBaseTransition'); + + // NOTE: this SDK's wasm binding for SetPrices rejects a BigInt value + // ("Price for amount '100' must be an integer") and requires a JS number. + // Guard against the >2^53 precision cliff since CREDITS_PER_TOKEN is a Number here. + if (CREDITS_PER_TOKEN > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('CREDITS_PER_TOKEN exceeds Number.MAX_SAFE_INTEGER; SetPrices needs a safe-integer price'); + } + const schedule = TokenPricingSchedule.SetPrices({ [MIN_PURCHASE.toString()]: Number(CREDITS_PER_TOKEN) }); + console.log('built SetPrices schedule'); + const setPriceTx = new TokenSetPriceForDirectPurchaseTransition({ + base, + price: schedule, + publicNote: `min ${MIN_PURCHASE} @ ${CREDITS_PER_TOKEN} credits/token`, + }); + console.log('built TokenSetPriceForDirectPurchaseTransition'); + + const tokenTransition = new TokenTransition(setPriceTx); + const batched = new BatchedTransition(tokenTransition); + const batchTransition = BatchTransition.fromBatchedTransitions([batched], OWNER_ID, 0); + st = batchTransition.toStateTransition(); + st.setIdentityContractNonce(nonce); + + const identity = await sdk.identities.fetch(OWNER_ID); + const identityKey = identity.getPublicKeyById(SIGNING_KEY_ID); + if (!identityKey) throw new Error(`public key ${SIGNING_KEY_ID} not found on identity`); + + st.sign(PrivateKey.fromWIF(signingKey.privateKeyWif), identityKey); + console.log('signed; broadcasting...'); +} catch (e) { + console.error('BUILD/SIGN ERROR:', describeErr(e)); + process.exit(1); +} + +try { + await sdk.stateTransitions.broadcastStateTransition(st); + await sdk.stateTransitions.waitForResponse(st); + console.log('broadcast confirmed'); +} catch (e) { + const msg = (e?.message || String(e)).toLowerCase(); + // The trusted SDK can't verify the result proof for a just-registered contract + // ("unknown contract ... in token verification"). The transition still applies — + // confirm by reading the price back rather than failing. + if (msg.includes('unknown contract')) { + console.warn('wait proof unverifiable (unknown contract) — confirming via price read…'); + } else { + console.error('BROADCAST ERROR:', describeErr(e)); + process.exit(1); + } +} + +const prices = await sdk.tokens.directPurchasePrices([tokenId]); +console.log('directPurchasePrices now:', prices); + +process.exit(0); diff --git a/types/sdk.ts b/types/sdk.ts index e660d19d..3551af83 100644 --- a/types/sdk.ts +++ b/types/sdk.ts @@ -167,8 +167,6 @@ export interface PostDocumentData { mediaUrl?: string; replyToPostId?: string | Uint8Array; quotedPostId?: string | Uint8Array; - firstMentionId?: string; - primaryHashtag?: string; language?: string; sensitive?: boolean; }