From 6c6632d8f18a2e1e88dd8c7536da571edeece0df Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 14:51:38 -0500 Subject: [PATCH 01/11] feat: v4 tokenomics + count trees (YAPP token, buy/moderation UX) Migrate to a protocol-v12 social contract with count-tree indexes and a native YAPP token. Posts/replies/likes/reposts now cost YAPP (revenue to the contract owner), bought via a minimum-100 direct-purchase bond, with owner-controlled freeze/slash moderation. Counts (likes/replies/followers/following/reposts) now use O(1) count trees instead of O(n) pagination. Token payment is auto-attached on all paid writes. Adds a Buy-YAPP modal, YAPP balance + Buy button, and insufficient-balance prompts across post/reply/like/repost. Reposts migrated to a dedicated 'repost' doctype. Bumps @dashevo/evo-sdk to 4.0.0-rc.2. Adds a legacy-app escape link on empty/end-of-feed states and scripts/set-yapp-price.mjs for tiered pricing. Co-Authored-By: Claude Opus 4.8 --- app/hashtag/page.tsx | 2 + app/settings/page.tsx | 14 +- components/compose/compose-modal.tsx | 9 +- components/feed/feed-post-list.tsx | 9 + components/layout/sidebar.tsx | 4 +- components/post/post-card.tsx | 14 +- components/providers.tsx | 2 + components/settings/moderation-settings.tsx | 95 ++ components/token/buy-yapp-modal.tsx | 253 +++++ components/token/yapp-balance-item.tsx | 49 + components/ui/legacy-yappr-link.tsx | 21 + components/ui/loading-state.tsx | 4 + contracts/yappr-social-contract-v2.json | 1002 +++++++++++++++++++ hooks/use-buy-yapp-modal.ts | 16 + lib/constants.ts | 20 +- lib/dash-platform-client.ts | 2 - lib/error-utils.ts | 19 + lib/services/follow-service.ts | 44 +- lib/services/like-service.ts | 25 +- lib/services/pagination-utils.ts | 28 + lib/services/post-service.ts | 18 - lib/services/reply-service.ts | 16 +- lib/services/repost-service.ts | 280 ++---- lib/services/state-transition-service.ts | 44 + lib/services/token-service.ts | 231 +++++ package-lock.json | 16 +- package.json | 2 +- scripts/set-yapp-price.mjs | 130 +++ 28 files changed, 2099 insertions(+), 270 deletions(-) create mode 100644 components/settings/moderation-settings.tsx create mode 100644 components/token/buy-yapp-modal.tsx create mode 100644 components/token/yapp-balance-item.tsx create mode 100644 components/ui/legacy-yappr-link.tsx create mode 100644 contracts/yappr-social-contract-v2.json create mode 100644 hooks/use-buy-yapp-modal.ts create mode 100644 lib/services/token-service.ts create mode 100644 scripts/set-yapp-price.mjs 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..96851f2c 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..6b28d98d 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' @@ -320,7 +321,8 @@ export function Sidebar() { - + diff --git a/components/post/post-card.tsx b/components/post/post-card.tsx index 25e2cc4e..4747f835 100644 --- a/components/post/post-card.tsx +++ b/components/post/post-card.tsx @@ -38,6 +38,8 @@ 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 { useBuyYappModal } from '@/hooks/use-buy-yapp-modal' +import { isInsufficientTokenError } from '@/lib/error-utils' import { useBlock } from '@/hooks/use-block' import { useFollow } from '@/hooks/use-follow' import { useHashtagValidation } from '@/hooks/use-hashtag-validation' @@ -364,7 +366,11 @@ 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 (isInsufficientTokenError(error)) { + useBuyYappModal.getState().open('You need YAPP to like posts. Buy some to continue.') + } else { + toast.error('Failed to update like. Please try again.') + } } finally { setLikeLoading(false) } @@ -397,7 +403,11 @@ 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 (isInsufficientTokenError(error)) { + useBuyYappModal.getState().open('You need YAPP to repost. Buy some to continue.') + } else { + toast.error('Failed to update repost. Please try again.') + } } finally { setRepostLoading(false) } 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..a50317cb --- /dev/null +++ b/components/settings/moderation-settings.tsx @@ -0,0 +1,95 @@ +'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..91d38615 --- /dev/null +++ b/components/token/buy-yapp-modal.tsx @@ -0,0 +1,253 @@ +'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' + +// Preset purchase amounts in whole YAPP (must be >= the on-chain minimum of 100). +const PRESETS = [100, 500, 1000, 5000] + +type ModalState = 'input' | 'confirming' | '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) + + useEffect(() => { + if (isOpen && user) { + setLoading(true) + 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) + } + }, [isOpen]) + + const amountNum = parseInt(amount, 10) || 0 + const costCredits = pricePerToken !== null ? pricePerToken * BigInt(amountNum || 0) : null + const costDashLabel = costCredits !== null + ? tipService.formatDash(tipService.creditsToDash(Number(costCredits))) + : '—' + + const handleAmountChange = (value: string) => { + if (/^\d*$/.test(value)) { + setAmount(value) + setError(null) + } + } + + const handleContinue = () => { + if (amountNum < Number(MIN_YAPP_PURCHASE)) { + setError(`Minimum purchase is ${MIN_YAPP_PURCHASE} YAPP`) + return + } + if (costCredits !== null && 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) return + setState('processing') + const result = await tokenService.buyYapp(user.identityId, BigInt(amountNum)) + if (result.success) { + 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 { + 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' : '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. +

+ +
+
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} +
+ + {error &&

{error}

} + + +
+ )} + + {state === 'confirming' && ( +
+
+
+ Buying + {amountNum} YAPP +
+
+ Cost + {costDashLabel} +
+
+
+ + +
+
+ )} + + {state === 'processing' && ( +
+ +

Purchasing YAPP…

+

Please wait, this may take a moment.

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

Purchased {amountNum} 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..61904cd2 --- /dev/null +++ b/components/token/yapp-balance-item.tsx @@ -0,0 +1,49 @@ +'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) + + useEffect(() => { + if (!user) return + const refresh = () => tokenService.getBalance(user.identityId).then(setYapp).catch(() => setYapp(null)) + refresh() + window.addEventListener('yapp-balance-changed', refresh) + return () => window.removeEventListener('yapp-balance-changed', refresh) + }, [user]) + + if (!user) return null + + return ( + <> + e.preventDefault()}> +
+
+
YAPP
+
{yapp !== null ? yapp.toString() : '…'}
+
+ +
+
+ + + ) +} 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..1a0eb96d --- /dev/null +++ b/hooks/use-buy-yapp-modal.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand' + +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 }), +})) 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..0a09a621 100644 --- a/lib/error-utils.ts +++ b/lib/error-utils.ts @@ -79,10 +79,29 @@ 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') || + 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/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/like-service.ts b/lib/services/like-service.ts index 4d533a6d..06dcab98 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 } 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; } } @@ -172,9 +178,18 @@ class LikeService extends BaseDocumentService { * Count likes for a post */ 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; + } } /** diff --git a/lib/services/pagination-utils.ts b/lib/services/pagination-utils.ts index 76efd6db..c347e1cc 100644 --- a/lib/services/pagination-utils.ts +++ b/lib/services/pagination-utils.ts @@ -34,6 +34,34 @@ export interface PaginateFetchResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any type SDK = any; +/** + * 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 + return Number(total ?? BigInt(0)); +} + /** * 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..5dc28772 100644 --- a/lib/services/post-service.ts +++ b/lib/services/post-service.ts @@ -206,8 +206,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 +259,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 +436,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/reply-service.ts b/lib/services/reply-service.ts index 7fa78ad8..1dcf73b2 100644 --- a/lib/services/reply-service.ts +++ b/lib/services/reply-service.ts @@ -5,6 +5,8 @@ import { dpnsService } from './dpns-service'; import { unifiedProfileService } from './unified-profile-service'; import { identifierToBase58, normalizeSDKResponse, RequestDeduplicator, identifierStringToDocumentBytes, normalizeBytes, createDefaultUser } from './sdk-helpers'; import type { EncryptionOptions } from './post-service'; +import { getEvoSdk } from './evo-sdk-service'; +import { documentCount } from './pagination-utils'; export interface ReplyDocument { $id: string; @@ -351,15 +353,13 @@ 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 + const sdk = await getEvoSdk(); + // 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', '==', parentId]], }); - return result.documents.length; } catch { return 0; } diff --git a/lib/services/repost-service.ts b/lib/services/repost-service.ts index 4fc40e27..5a335699 100644 --- a/lib/services/repost-service.ts +++ b/lib/services/repost-service.ts @@ -2,115 +2,82 @@ 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 } 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; - - // 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 documentType = 'repost'; - const quotedPostId = identifierToBase58(rawQuotedPostId); - if (!quotedPostId) { - logger.error('RepostService: Invalid quotedPostId format:', rawQuotedPostId); - return null; - } - - // Convert quotedPostOwnerId - const rawQuotedPostOwnerId = data.quotedPostOwnerId || doc.quotedPostOwnerId; - const quotedPostOwnerId = rawQuotedPostOwnerId ? identifierToBase58(rawQuotedPostOwnerId) : undefined; + private async sdk() { + return import('../services/evo-sdk-service').then(m => m.getEvoSdk()); + } + 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) return null; + 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 +85,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 +98,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 +124,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 +145,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); @@ -238,49 +166,39 @@ class RepostService { } } - /** - * Count reposts for a post - */ + /** 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. - */ + /** 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 +206,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/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/token-service.ts b/lib/services/token-service.ts new file mode 100644 index 00000000..0e2ef812 --- /dev/null +++ b/lib/services/token-service.ts @@ -0,0 +1,231 @@ +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'; +} + +/** 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; + } + + /** Current YAPP balance (whole tokens, decimals=0) for an identity. */ + async getBalance(identityId: string): Promise { + try { + const sdk = await getEvoSdk(); + const tokenId = await this.getTokenId(); + const balances = await sdk.tokens.identityBalances(identityId, [tokenId]); + const bal = balances instanceof Map ? balances.get(tokenId) : (balances as Record)?.[tokenId]; + return bal ?? BigInt(0); + } catch (error) { + logger.error('Error fetching YAPP balance:', error); + return 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 = prices instanceof Map ? prices.get(tokenId) : (prices as Record)?.[tokenId]; + if (!info) return null; + const current = (info as { currentPrice?: string }).currentPrice; + if (current === undefined) return null; + return BigInt(current); + } catch (error) { + logger.error('Error fetching YAPP price:', error); + return null; + } + } + + /** Total credits cost to buy `amount` YAPP at the current price. */ + async quoteCost(amount: bigint): Promise { + const price = await this.getPricePerToken(); + if (price === null) return null; + return price * amount; + } + + /** + * Buy `amount` YAPP for the buyer, paying up to the quoted credits cost. + * Requires the buyer's AUTHENTICATION key (CRITICAL/HIGH) from secure storage. + */ + async buyYapp(buyerId: string, amount: bigint): 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 price = await this.getPricePerToken(); + if (price === null) { + return { success: false, error: 'YAPP is not currently for sale', errorCode: 'NETWORK_ERROR' }; + } + const maxTotalCost = price * amount; + + const { signer, identityKey } = await this.getAuthSigner(buyerId); + + 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, + * using the private key from secure storage. Mirrors tip-service's transfer-key flow. + */ + private async getAuthSigner(identityId: string) { + const { getPrivateKey } = await import('../secure-storage'); + const wif = getPrivateKey(identityId); + if (!wif) { + const { promptForAuthKey } = await import('../auth-utils'); + promptForAuthKey(); + throw new Error('Private key not found — please re-authenticate'); + } + + const sdk = await getEvoSdk(); + const identity = await sdk.identities.fetch(identityId); + if (!identity) throw new Error('Identity not found'); + + const authKey = this.findMatchingAuthKey(wif.trim(), identity.publicKeys); + if (!authKey) { + throw new Error('No matching AUTHENTICATION key (CRITICAL/HIGH) found for the stored private key'); + } + + return signerService.createSignerFromWasmKey(wif.trim(), authKey); + } + + /** Find the AUTHENTICATION key on the identity that matches the provided WIF. */ + private findMatchingAuthKey( + wif: string, + wasmPublicKeys: WasmIdentityPublicKey[] + ): WasmIdentityPublicKey | null { + const network = (process.env.NEXT_PUBLIC_NETWORK as 'testnet' | 'mainnet') || 'testnet'; + const authKeys = wasmPublicKeys + .filter(k => !k.disabledAt && k.purposeNumber === KeyPurpose.AUTHENTICATION); + 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; + // Token transitions need CRITICAL or HIGH; reject MASTER/MEDIUM matches so the + // caller surfaces a clear error instead of a cryptic node-side signature failure. + const matched = authKeys.find(k => k.keyId === match.keyId) || null; + if (matched && (matched.securityLevelNumber === SecurityLevel.CRITICAL || matched.securityLevelNumber === SecurityLevel.HIGH)) { + return matched; + } + return null; + } + + private toResult(error: unknown, fallback: string): TokenResult { + const msg = extractErrorMessage(error); + logger.error(`${fallback}:`, msg); + const lower = msg.toLowerCase(); + if (lower.includes('not authorized') || lower.includes('noone')) { + return { success: false, error: 'Not authorized to perform this action', errorCode: 'NOT_AUTHORIZED' }; + } + if (lower.includes('enough') && lower.includes('credit')) { + return { success: false, error: 'Insufficient credits to complete the purchase', errorCode: 'INSUFFICIENT_CREDITS' }; + } + if (lower.includes('undeminimum') || 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('key') || lower.includes('signature') || lower.includes('security')) { + return { success: false, error: 'Invalid 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..dac13d63 --- /dev/null +++ b/scripts/set-yapp-price.mjs @@ -0,0 +1,130 @@ +/** + * 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'); + + 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); From 0652fc9de1a71e173f281f1e0b0f595b0553c677 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 15:41:36 -0500 Subject: [PATCH 02/11] fix: address PR review on v4 tokenomics - token-service: robust token-id Map lookup (SDK Maps may be Identifier-keyed) fixing balance/price/buy; pre-filter to CRITICAL/HIGH auth keys before matching so a lower-security key can't mask a valid one - yapp-balance-item: make the Buy row a keyboard-operable DropdownMenu.Item instead of a button nested in a disabled item - post-stats-helpers: batch feed stats now use per-post O(1) count trees (removes the 100-cap undercount on the feed path) - remove stale firstMentionId/primaryHashtag from PostDocument/PostDocumentData types (v2 schema drops them) Co-Authored-By: Claude Opus 4.8 --- components/token/yapp-balance-item.tsx | 16 +++++---- lib/services/post-service.ts | 2 -- lib/services/post-stats-helpers.ts | 30 +++++++--------- lib/services/token-service.ts | 47 +++++++++++++++++++------- types/sdk.ts | 2 -- 5 files changed, 56 insertions(+), 41 deletions(-) diff --git a/components/token/yapp-balance-item.tsx b/components/token/yapp-balance-item.tsx index 61904cd2..d5908ac9 100644 --- a/components/token/yapp-balance-item.tsx +++ b/components/token/yapp-balance-item.tsx @@ -28,19 +28,21 @@ export function YappBalanceItem() { return ( <> - e.preventDefault()}> + {/* 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() : '…'}
- +
diff --git a/lib/services/post-service.ts b/lib/services/post-service.ts index 5dc28772..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 diff --git a/lib/services/post-stats-helpers.ts b/lib/services/post-stats-helpers.ts index c058697a..f4c3aacc 100644 --- a/lib/services/post-stats-helpers.ts +++ b/lib/services/post-stats-helpers.ts @@ -155,25 +155,21 @@ export async function fetchBatchPostStats(postIds: string[]): Promise likeService.countLikes(id))), + Promise.all(postIds.map((id) => repostService.countReposts(id))), + Promise.all(postIds.map((id) => replyService.countReplies(id))), ]); - for (const like of likes) { - const stats = result.get(like.postId); - if (stats) stats.likes++; - } - - for (const repost of reposts) { - const stats = result.get(repost.postId); - if (stats) stats.reposts++; - } - - replyCounts.forEach((count, postId) => { - const stats = result.get(postId); - if (stats) stats.replies = count; + postIds.forEach((id, i) => { + const stats = result.get(id); + if (stats) { + stats.likes = likeCounts[i]; + stats.reposts = repostCounts[i]; + stats.replies = replyCounts[i]; + } }); } catch (error) { logger.error('Error getting batch post stats:', error); diff --git a/lib/services/token-service.ts b/lib/services/token-service.ts index 0e2ef812..e4cd24a0 100644 --- a/lib/services/token-service.ts +++ b/lib/services/token-service.ts @@ -27,14 +27,35 @@ class TokenService { 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. */ async getBalance(identityId: string): Promise { try { const sdk = await getEvoSdk(); const tokenId = await this.getTokenId(); const balances = await sdk.tokens.identityBalances(identityId, [tokenId]); - const bal = balances instanceof Map ? balances.get(tokenId) : (balances as Record)?.[tokenId]; - return bal ?? BigInt(0); + return this.tokenMapGet(balances, tokenId) ?? BigInt(0); } catch (error) { logger.error('Error fetching YAPP balance:', error); return BigInt(0); @@ -50,9 +71,9 @@ class TokenService { const sdk = await getEvoSdk(); const tokenId = await this.getTokenId(); const prices = await sdk.tokens.directPurchasePrices([tokenId]); - const info = prices instanceof Map ? prices.get(tokenId) : (prices as Record)?.[tokenId]; + const info = this.tokenMapGet<{ currentPrice?: string }>(prices, tokenId); if (!info) return null; - const current = (info as { currentPrice?: string }).currentPrice; + const current = info.currentPrice; if (current === undefined) return null; return BigInt(current); } catch (error) { @@ -185,8 +206,14 @@ class TokenService { wasmPublicKeys: WasmIdentityPublicKey[] ): WasmIdentityPublicKey | null { const network = (process.env.NEXT_PUBLIC_NETWORK as 'testnet' | 'mainnet') || 'testnet'; - const authKeys = wasmPublicKeys - .filter(k => !k.disabledAt && k.purposeNumber === KeyPurpose.AUTHENTICATION); + // Token transitions need a CRITICAL or HIGH AUTHENTICATION key. Filter to those + // 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 && + (k.securityLevelNumber === SecurityLevel.CRITICAL || k.securityLevelNumber === SecurityLevel.HIGH) + ); if (authKeys.length === 0) return null; const keyInfos: IdentityPublicKeyInfo[] = authKeys.map(key => ({ @@ -199,13 +226,7 @@ class TokenService { const match = findMatchingKeyIndex(wif, keyInfos, network); if (!match) return null; - // Token transitions need CRITICAL or HIGH; reject MASTER/MEDIUM matches so the - // caller surfaces a clear error instead of a cryptic node-side signature failure. - const matched = authKeys.find(k => k.keyId === match.keyId) || null; - if (matched && (matched.securityLevelNumber === SecurityLevel.CRITICAL || matched.securityLevelNumber === SecurityLevel.HIGH)) { - return matched; - } - return null; + return authKeys.find(k => k.keyId === match.keyId) || null; } private toResult(error: unknown, fallback: string): TokenResult { 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; } From 911ee70f3eaf7141c49f68fb8c00689e72d250bf Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 16:02:22 -0500 Subject: [PATCH 03/11] fix: address PR review round 2 (buy cap + BigInt-safe amounts) - buyYapp now takes the user-approved maxTotalCost (the quoted total the modal showed) instead of refetching a fresh price at sign time, so a mid-flight YAPP price increase is rejected on-chain rather than silently overspending; classify the price-too-low rejection as a re-quote prompt - buy modal keeps amount and cost as BigInt end-to-end (no parseInt/Number round-trip through the signed path) and formats credits->DASH with a BigInt-safe helper Co-Authored-By: Claude Opus 4.8 --- components/token/buy-yapp-modal.tsx | 40 ++++++++++++++++++++--------- lib/services/token-service.ts | 17 ++++++------ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/components/token/buy-yapp-modal.tsx b/components/token/buy-yapp-modal.tsx index 91d38615..4c5d7251 100644 --- a/components/token/buy-yapp-modal.tsx +++ b/components/token/buy-yapp-modal.tsx @@ -17,6 +17,16 @@ import { tipService } from '@/lib/services/tip-service' // Preset purchase amounts in whole YAPP (must be >= the on-chain minimum of 100). const PRESETS = [100, 500, 1000, 5000] +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' | 'processing' | 'success' | 'error' export function BuyYappModal() { @@ -50,11 +60,11 @@ export function BuyYappModal() { } }, [isOpen]) - const amountNum = parseInt(amount, 10) || 0 - const costCredits = pricePerToken !== null ? pricePerToken * BigInt(amountNum || 0) : null - const costDashLabel = costCredits !== null - ? tipService.formatDash(tipService.creditsToDash(Number(costCredits))) - : '—' + // 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)) { @@ -64,11 +74,15 @@ export function BuyYappModal() { } const handleContinue = () => { - if (amountNum < Number(MIN_YAPP_PURCHASE)) { + if (amountBig < MIN_YAPP_PURCHASE) { setError(`Minimum purchase is ${MIN_YAPP_PURCHASE} YAPP`) return } - if (costCredits !== null && creditBalance !== null && costCredits > BigInt(Math.floor(creditBalance))) { + 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 } @@ -77,9 +91,11 @@ export function BuyYappModal() { } const handleBuy = async () => { - if (!user) return + if (!user || costCredits === null) return setState('processing') - const result = await tokenService.buyYapp(user.identityId, BigInt(amountNum)) + // 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 result = await tokenService.buyYapp(user.identityId, amountBig, costCredits) if (result.success) { tokenService.getBalance(user.identityId).then(setYappBalance).catch(() => {}) refreshBalance().catch(err => logger.error('Failed to refresh balance:', err)) @@ -182,7 +198,7 @@ export function BuyYappModal() { {error &&

{error}

} - @@ -193,7 +209,7 @@ export function BuyYappModal() {
Buying - {amountNum} YAPP + {amountBig.toString()} YAPP
Cost @@ -219,7 +235,7 @@ export function BuyYappModal() {
-

Purchased {amountNum} YAPP

+

Purchased {amountBig.toString()} YAPP

{yappBalance !== null && (

New balance: {yappBalance.toString()} YAPP

)} diff --git a/lib/services/token-service.ts b/lib/services/token-service.ts index e4cd24a0..07090046 100644 --- a/lib/services/token-service.ts +++ b/lib/services/token-service.ts @@ -90,22 +90,18 @@ class TokenService { } /** - * Buy `amount` YAPP for the buyer, paying up to the quoted credits cost. - * Requires the buyer's AUTHENTICATION key (CRITICAL/HIGH) from secure storage. + * 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. Requires the buyer's CRITICAL/HIGH auth key. */ - async buyYapp(buyerId: string, amount: bigint): Promise { + async buyYapp(buyerId: string, amount: bigint, maxTotalCost: bigint): 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 price = await this.getPricePerToken(); - if (price === null) { - return { success: false, error: 'YAPP is not currently for sale', errorCode: 'NETWORK_ERROR' }; - } - const maxTotalCost = price * amount; - const { signer, identityKey } = await this.getAuthSigner(buyerId); await sdk.tokens.directPurchase({ @@ -242,6 +238,9 @@ class TokenService { if (lower.includes('undeminimum') || 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' }; + } if (lower.includes('key') || lower.includes('signature') || lower.includes('security')) { return { success: false, error: 'Invalid signing key for this identity', errorCode: 'INVALID_KEY' }; } From 29db28690c24d5852828fc3639797f9dc91bb6bb Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 16:26:46 -0500 Subject: [PATCH 04/11] fix: address CodeRabbit review (settings title, a11y labels, matcher typo) - settings header title now includes the moderation section (was falling back to 'Settings') - fix 'underminimum' matcher typo so compact under-minimum errors classify correctly - associate moderation form labels with inputs via htmlFor/id - set-yapp-price: keep Number for SetPrices (the wasm binding rejects BigInt) but guard against the >2^53 precision cliff Co-Authored-By: Claude Opus 4.8 --- app/settings/page.tsx | 2 +- components/settings/moderation-settings.tsx | 6 ++++-- lib/services/token-service.ts | 2 +- scripts/set-yapp-price.mjs | 6 ++++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 96851f2c..989c7fb6 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -749,7 +749,7 @@ function SettingsPage() { const getSectionTitle = () => { if (activeSection === 'main') return 'Settings' - const section = settingsSections.find(s => s.id === activeSection) + const section = [...settingsSections, MODERATION_SECTION].find(s => s.id === activeSection) return section?.label || 'Settings' } diff --git a/components/settings/moderation-settings.tsx b/components/settings/moderation-settings.tsx index a50317cb..eefc20aa 100644 --- a/components/settings/moderation-settings.tsx +++ b/components/settings/moderation-settings.tsx @@ -59,8 +59,9 @@ export function ModerationSettings() {
- + setTargetId(e.target.value)} @@ -69,8 +70,9 @@ export function ModerationSettings() { />
- + setNote(e.target.value)} diff --git a/lib/services/token-service.ts b/lib/services/token-service.ts index 07090046..f70925fc 100644 --- a/lib/services/token-service.ts +++ b/lib/services/token-service.ts @@ -235,7 +235,7 @@ class TokenService { if (lower.includes('enough') && lower.includes('credit')) { return { success: false, error: 'Insufficient credits to complete the purchase', errorCode: 'INSUFFICIENT_CREDITS' }; } - if (lower.includes('undeminimum') || lower.includes('under minimum') || lower.includes('minimum sale')) { + 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')) { diff --git a/scripts/set-yapp-price.mjs b/scripts/set-yapp-price.mjs index dac13d63..8b10a236 100644 --- a/scripts/set-yapp-price.mjs +++ b/scripts/set-yapp-price.mjs @@ -81,6 +81,12 @@ try { }); 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({ From 028c71638329013f4a6ae89b60f211162892af5e Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 17:17:06 -0500 Subject: [PATCH 05/11] fix: resolve deep-review findings on v4 tokenomics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: surface tip announcement-reply failures for 0-YAPP tippers (#7); user like/repost state now queries the user's own docs via composite indexes instead of capped all-user queries (#8); getBalance no longer swallows failures to 0 (#11); broaden insufficient-credits error classification and stop misclassifying network/proof errors as bad-key (#9,#10); gate the settings Moderation title by isAuthority (#12); bound the per-post count fan-out concurrency (#1); log missing count-tree totals (#13); log invalid repost postIds (#4). Cleanup: extract shared handleInsufficientYapp helper (was copy-pasted 3x, #5); remove dead quoteCost (#6) and dead reply-batch methods (#14). Deferred: cross-service key-matching dedup (#2) — a broad refactor of pre-existing signing code, out of scope. Grouped-count optimization for #1 deferred until the hex group-key layout can be verified against live data. Co-Authored-By: Claude Opus 4.8 --- app/settings/page.tsx | 6 +++- components/compose/compose-modal.tsx | 8 ++--- components/post/post-card.tsx | 11 ++---- components/post/tip-modal.tsx | 8 +++++ hooks/use-buy-yapp-modal.ts | 14 ++++++++ lib/services/like-service.ts | 28 +++++++++++++++ lib/services/pagination-utils.ts | 34 +++++++++++++++++- lib/services/post-stats-helpers.ts | 26 +++++++------- lib/services/reply-service.ts | 53 +--------------------------- lib/services/repost-service.ts | 33 ++++++++++++++++- lib/services/tip-service.ts | 31 +++++++++++----- lib/services/token-service.ts | 43 +++++++++++----------- 12 files changed, 184 insertions(+), 111 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 989c7fb6..8c57445e 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -749,7 +749,11 @@ function SettingsPage() { const getSectionTitle = () => { if (activeSection === 'main') return 'Settings' - const section = [...settingsSections, MODERATION_SECTION].find(s => s.id === activeSection) + // Only surface the Moderation title to the authority (matches renderSection's + // gate) so a non-authority hitting ?section=moderation doesn't see a + // "Moderation" header over the fallback main menu. + const sections = isAuthority ? [...settingsSections, MODERATION_SECTION] : settingsSections + const section = sections.find(s => s.id === activeSection) return section?.label || 'Settings' } diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index 20c47a47..b4aa1cd1 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -22,8 +22,8 @@ import { UserAvatar } from '@/components/ui/avatar-image' import { extractAllTags, extractMentions } from '@/lib/post-helpers' import { hashtagService } from '@/lib/services/hashtag-service' import { mentionService } from '@/lib/services/mention-service' -import { extractErrorMessage, isTimeoutError, categorizeError, isInsufficientTokenError } from '@/lib/error-utils' -import { useBuyYappModal } from '@/hooks/use-buy-yapp-modal' +import { extractErrorMessage, isTimeoutError, categorizeError } from '@/lib/error-utils' +import { handleInsufficientYapp } from '@/hooks/use-buy-yapp-modal' import { PostingProgress, PostButtonContent, @@ -903,9 +903,7 @@ export function ComposeModal() { } } catch (error) { logger.error('Failed to create post:', error) - if (isInsufficientTokenError(error)) { - useBuyYappModal.getState().open('You need YAPP to post. Buy some to continue.') - } else { + if (!handleInsufficientYapp(error, 'You need YAPP to post. Buy some to continue.')) { toast.error(categorizeError(error)) } } finally { diff --git a/components/post/post-card.tsx b/components/post/post-card.tsx index 4747f835..c20e35a6 100644 --- a/components/post/post-card.tsx +++ b/components/post/post-card.tsx @@ -38,8 +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 { useBuyYappModal } from '@/hooks/use-buy-yapp-modal' -import { isInsufficientTokenError } from '@/lib/error-utils' +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' @@ -366,9 +365,7 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e setLiked(wasLiked) setLikes(prevLikes) logger.error('Like error:', error) - if (isInsufficientTokenError(error)) { - useBuyYappModal.getState().open('You need YAPP to like posts. Buy some to continue.') - } else { + if (!handleInsufficientYapp(error, 'You need YAPP to like posts. Buy some to continue.')) { toast.error('Failed to update like. Please try again.') } } finally { @@ -403,9 +400,7 @@ export function PostCard({ post, hideAvatar = false, isOwnPost: isOwnPostProp, e setReposted(wasReposted) setReposts(prevReposts) logger.error('Repost error:', error) - if (isInsufficientTokenError(error)) { - useBuyYappModal.getState().open('You need YAPP to repost. Buy some to continue.') - } else { + if (!handleInsufficientYapp(error, 'You need YAPP to repost. Buy some to continue.')) { toast.error('Failed to update repost. Please try again.') } } finally { diff --git a/components/post/tip-modal.tsx b/components/post/tip-modal.tsx index 29c78ac0..b8377c5d 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,13 @@ 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. + if (result.announcementPosted === false) { + toast('Tip sent — but the public tip note couldn’t be posted (needs YAPP).', { 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/hooks/use-buy-yapp-modal.ts b/hooks/use-buy-yapp-modal.ts index 1a0eb96d..babb41fd 100644 --- a/hooks/use-buy-yapp-modal.ts +++ b/hooks/use-buy-yapp-modal.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { isInsufficientTokenError } from '@/lib/error-utils' interface BuyYappModalStore { isOpen: boolean @@ -14,3 +15,16 @@ export const useBuyYappModal = create((set) => ({ 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/services/like-service.ts b/lib/services/like-service.ts index 06dcab98..956a7a84 100644 --- a/lib/services/like-service.ts +++ b/lib/services/like-service.ts @@ -177,6 +177,34 @@ 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 response = await sdk.documents.query({ + dataContractId: this.contractId, + documentTypeName: 'like', + where: [['postId', 'in', postIds], ['$ownerId', '==', userId]], + orderBy: [['postId', 'asc'], ['$ownerId', 'asc']], + limit: 100, + }); + const liked = new Set(); + 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 { try { const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); diff --git a/lib/services/pagination-utils.ts b/lib/services/pagination-utils.ts index c347e1cc..89e76929 100644 --- a/lib/services/pagination-utils.ts +++ b/lib/services/pagination-utils.ts @@ -9,6 +9,7 @@ * for both counting and fetching complete lists. */ +import { logger } from '@/lib/logger'; import { normalizeSDKResponse } from './sdk-helpers'; export interface PaginateOptions { @@ -34,6 +35,29 @@ export interface PaginateFetchResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any type SDK = any; +/** + * 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)). * @@ -59,7 +83,15 @@ export async function documentCount( 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 - return Number(total ?? BigInt(0)); + 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); } /** diff --git a/lib/services/post-stats-helpers.ts b/lib/services/post-stats-helpers.ts index f4c3aacc..651c8506 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) => { @@ -156,11 +153,14 @@ export async function fetchBatchPostStats(postIds: string[]): Promise likeService.countLikes(id))), - Promise.all(postIds.map((id) => repostService.countReposts(id))), - Promise.all(postIds.map((id) => replyService.countReplies(id))), + mapLimit(postIds, COUNT_CONCURRENCY, (id) => likeService.countLikes(id)), + mapLimit(postIds, COUNT_CONCURRENCY, (id) => repostService.countReposts(id)), + mapLimit(postIds, COUNT_CONCURRENCY, (id) => replyService.countReplies(id)), ]); postIds.forEach((id, i) => { diff --git a/lib/services/reply-service.ts b/lib/services/reply-service.ts index 1dcf73b2..9726b331 100644 --- a/lib/services/reply-service.ts +++ b/lib/services/reply-service.ts @@ -3,7 +3,7 @@ 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 } from './pagination-utils'; @@ -34,8 +34,6 @@ export interface EncryptionSource { } class ReplyService extends BaseDocumentService { - // Request deduplicators for batch operations - private repliesDeduplicator = new RequestDeduplicator>(); constructor() { super('reply'); @@ -365,55 +363,6 @@ class ReplyService extends BaseDocumentService { } } - /** - * 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({ - dataContractId: this.contractId, - documentTypeName: 'reply', - where: [['parentId', 'in', parentIds]], - orderBy: [['parentId', 'asc']], - limit: 100 - }); - - 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); - } - - return result; - } - /** * Get reply by ID */ diff --git a/lib/services/repost-service.ts b/lib/services/repost-service.ts index 5a335699..1a430061 100644 --- a/lib/services/repost-service.ts +++ b/lib/services/repost-service.ts @@ -31,7 +31,10 @@ class RepostService { const data = (doc.data || doc) as Record; const rawPostId = data.postId || doc.postId; const postId = rawPostId ? identifierToBase58(rawPostId) : null; - if (!postId) return null; + if (!postId) { + logger.error('RepostService: repost doc has invalid/missing postId:', rawPostId); + return null; + } const rawOwner = data.postOwnerId || doc.postOwnerId; return { $id: (doc.$id || doc.id) as string, @@ -166,6 +169,34 @@ class RepostService { } } + /** + * 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 response = await sdk.documents.query({ + dataContractId: this.contractId, + documentTypeName: this.documentType, + where: [['$ownerId', '==', userId], ['postId', 'in', postIds]], + orderBy: [['$ownerId', 'asc'], ['postId', 'asc']], + limit: 100, + }); + const out = new Set(); + 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 { try { diff --git a/lib/services/tip-service.ts b/lib/services/tip-service.ts index a2f0e8f6..7c69ef5e 100644 --- a/lib/services/tip-service.ts +++ b/lib/services/tip-service.ts @@ -12,6 +12,12 @@ 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; } // Regex to parse tip content: tip:AMOUNT_CREDITS followed by optional message @@ -245,14 +251,18 @@ 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 announcementPosted = postId + ? await this.createTipPost(senderId, postId, recipientId, amountCredits, message) + : true; return { success: true, // TODO: Return actual transaction hash once SDK exposes it - transactionHash: 'confirmed' + transactionHash: 'confirmed', + announcementPosted, }; } catch (error) { @@ -268,13 +278,14 @@ 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 announcementPosted = postId + ? await this.createTipPost(senderId, postId, recipientId, amountCredits, message) + : true; return { success: true, - transactionHash: 'pending-confirmation' + transactionHash: 'pending-confirmation', + announcementPosted, }; } @@ -318,7 +329,7 @@ class TipService { postOwnerId: string, amountCredits: number, tipMessage?: string - ): Promise { + ): Promise { try { // Format: tip:CREDITS\nmessage (message is optional) // Amount is self-reported until SDK provides transition ID for verification @@ -331,9 +342,11 @@ class TipService { await replyService.createReply(senderId, content, postId, postOwnerId); logger.info('Tip reply created successfully'); + return true; } catch (error) { // Log but don't fail the tip - the credit transfer already succeeded logger.error('Failed to create tip post:', error); + return false; } } diff --git a/lib/services/token-service.ts b/lib/services/token-service.ts index f70925fc..3b41292b 100644 --- a/lib/services/token-service.ts +++ b/lib/services/token-service.ts @@ -49,17 +49,16 @@ class TokenService { return (result as Record)?.[tokenId]; } - /** Current YAPP balance (whole tokens, decimals=0) for an identity. */ + /** + * 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 { - try { - const sdk = await getEvoSdk(); - const tokenId = await this.getTokenId(); - const balances = await sdk.tokens.identityBalances(identityId, [tokenId]); - return this.tokenMapGet(balances, tokenId) ?? BigInt(0); - } catch (error) { - logger.error('Error fetching YAPP balance:', error); - return BigInt(0); - } + const sdk = await getEvoSdk(); + const tokenId = await this.getTokenId(); + const balances = await sdk.tokens.identityBalances(identityId, [tokenId]); + return this.tokenMapGet(balances, tokenId) ?? BigInt(0); } /** @@ -82,13 +81,6 @@ class TokenService { } } - /** Total credits cost to buy `amount` YAPP at the current price. */ - async quoteCost(amount: bigint): Promise { - const price = await this.getPricePerToken(); - if (price === null) return null; - return price * amount; - } - /** * Buy `amount` YAPP for the buyer, spending at most `maxTotalCost` credits. * `maxTotalCost` is the cost the user approved in the UI (caller passes the @@ -232,8 +224,14 @@ class TokenService { if (lower.includes('not authorized') || lower.includes('noone')) { return { success: false, error: 'Not authorized to perform this action', errorCode: 'NOT_AUTHORIZED' }; } - if (lower.includes('enough') && lower.includes('credit')) { - return { success: false, error: 'Insufficient credits to complete the purchase', errorCode: 'INSUFFICIENT_CREDITS' }; + // 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' }; @@ -241,8 +239,11 @@ class TokenService { 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' }; } - if (lower.includes('key') || lower.includes('signature') || lower.includes('security')) { - return { success: false, error: 'Invalid signing key for this identity', errorCode: 'INVALID_KEY' }; + // 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' }; } From 5efbafa8604d07b499c8a6adbd8beb4f3f77d310 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 1 Jul 2026 23:52:36 -0500 Subject: [PATCH 06/11] perf: batch feed-page count-tree reads via grouped DAPI queries fetchBatchPostStats fired 3xN individual count-tree calls per feed page (one per like/repost/reply per post), previously only bounded to 6-concurrent rather than fixed. Replace with a single grouped sdk.documents.count({ groupBy }) call per stat type, decoding the SDK's hex-keyed grouped response back to base58 postIds via the new identifierToHex helper. Falls back to the proven per-post reads if the grouped response doesn't decode against expected keys (unverified against live testnet data), so a wrong hex-encoding assumption degrades to the old behavior instead of silently reporting zero counts. --- lib/services/like-service.ts | 13 +++++- lib/services/pagination-utils.ts | 70 +++++++++++++++++++++++++++++- lib/services/post-stats-helpers.ts | 23 +++++----- lib/services/reply-service.ts | 13 +++++- lib/services/repost-service.ts | 13 +++++- lib/services/sdk-helpers.ts | 12 +++++ 6 files changed, 128 insertions(+), 16 deletions(-) diff --git a/lib/services/like-service.ts b/lib/services/like-service.ts index 956a7a84..7938391e 100644 --- a/lib/services/like-service.ts +++ b/lib/services/like-service.ts @@ -2,7 +2,7 @@ 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, documentCount } from './pagination-utils'; +import { paginateFetchAll, documentCount, groupedDocumentCount } from './pagination-utils'; import { isInsufficientTokenError } from '../error-utils'; export interface LikeDocument { @@ -220,6 +220,17 @@ class LikeService extends BaseDocumentService { } } + /** 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) + ); + } + /** * Get likes for multiple posts in a single batch query * Uses 'in' operator for efficient querying diff --git a/lib/services/pagination-utils.ts b/lib/services/pagination-utils.ts index 89e76929..95e8a349 100644 --- a/lib/services/pagination-utils.ts +++ b/lib/services/pagination-utils.ts @@ -10,7 +10,7 @@ */ import { logger } from '@/lib/logger'; -import { normalizeSDKResponse } from './sdk-helpers'; +import { normalizeSDKResponse, identifierToHex } from './sdk-helpers'; export interface PaginateOptions { /** Maximum results to return (safety limit). Default: 1000 */ @@ -94,6 +94,74 @@ export async function documentCount( 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)); + + const hexToId = new Map(); + ids.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', ids]], + groupBy: [query.groupField], + limit: ids.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'); + } + + return result; + } 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(ids, 6, fallbackCount); + ids.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-stats-helpers.ts b/lib/services/post-stats-helpers.ts index 651c8506..1c04f848 100644 --- a/lib/services/post-stats-helpers.ts +++ b/lib/services/post-stats-helpers.ts @@ -152,23 +152,22 @@ export async function fetchBatchPostStats(postIds: string[]): Promise likeService.countLikes(id)), - mapLimit(postIds, COUNT_CONCURRENCY, (id) => repostService.countReposts(id)), - mapLimit(postIds, COUNT_CONCURRENCY, (id) => replyService.countReplies(id)), + likeService.countLikesForPosts(postIds), + repostService.countRepostsForPosts(postIds), + replyService.countRepliesForPosts(postIds), ]); - postIds.forEach((id, i) => { + postIds.forEach((id) => { const stats = result.get(id); if (stats) { - stats.likes = likeCounts[i]; - stats.reposts = repostCounts[i]; - stats.replies = replyCounts[i]; + stats.likes = likeCounts.get(id) ?? 0; + stats.reposts = repostCounts.get(id) ?? 0; + stats.replies = replyCounts.get(id) ?? 0; } }); } catch (error) { diff --git a/lib/services/reply-service.ts b/lib/services/reply-service.ts index 9726b331..54bdca22 100644 --- a/lib/services/reply-service.ts +++ b/lib/services/reply-service.ts @@ -6,7 +6,7 @@ import { unifiedProfileService } from './unified-profile-service'; import { identifierToBase58, normalizeSDKResponse, identifierStringToDocumentBytes, normalizeBytes, createDefaultUser } from './sdk-helpers'; import type { EncryptionOptions } from './post-service'; import { getEvoSdk } from './evo-sdk-service'; -import { documentCount } from './pagination-utils'; +import { documentCount, groupedDocumentCount } from './pagination-utils'; export interface ReplyDocument { $id: string; @@ -363,6 +363,17 @@ class ReplyService extends BaseDocumentService { } } + /** 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) + ); + } + /** * Get reply by ID */ diff --git a/lib/services/repost-service.ts b/lib/services/repost-service.ts index 1a430061..ee1b1c48 100644 --- a/lib/services/repost-service.ts +++ b/lib/services/repost-service.ts @@ -2,7 +2,7 @@ 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, documentCount } from './pagination-utils'; +import { paginateFetchAll, documentCount, groupedDocumentCount } from './pagination-utils'; import { isInsufficientTokenError } from '../error-utils'; /** A repost of a post — a dedicated `repost` document ({ postId, postOwnerId }). */ @@ -212,6 +212,17 @@ class RepostService { } } + /** 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 []; 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. * From af0ae273bf050e74305a97ab30b30d3823915196 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 2 Jul 2026 11:18:16 -0500 Subject: [PATCH 07/11] fix: detect Drive insufficient-token-balance phrasing so Buy-YAPP modal opens The on-chain error reads 'does not have enough balance for token X: required N, actual 0' which none of the isInsufficientTokenError patterns matched, so failed posts showed the raw broadcast error instead of the Buy-YAPP prompt. Also route mid-thread partial failures through the same handler. Co-Authored-By: Claude Fable 5 --- components/compose/compose-modal.tsx | 8 +++++++- lib/error-utils.ts | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index b4aa1cd1..830dcc4f 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -885,7 +885,13 @@ export function ComposeModal() { } const successPart = parts.join(', ') - const errorMsg = failureError?.message || 'Unknown error' + const ranOutOfYapp = handleInsufficientYapp( + failureError, + 'You ran out of YAPP mid-thread. Buy some to post the rest.' + ) + const errorMsg = ranOutOfYapp + ? 'not enough YAPP' + : failureError?.message || 'Unknown error' toast.error( `Thread partially created: ${successPart}. ` + `Post ${(failedAtIndex ?? 0) + 1} failed: ${errorMsg}. Press Post to retry.`, diff --git a/lib/error-utils.ts b/lib/error-utils.ts index 0a09a621..8984ebd2 100644 --- a/lib/error-utils.ts +++ b/lib/error-utils.ts @@ -90,6 +90,9 @@ export function isInsufficientTokenError(error: unknown): boolean { 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') ) } From deb20a2ba64f70a6597da098c6fa00bdeef281eb Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 2 Jul 2026 11:25:50 -0500 Subject: [PATCH 08/11] feat: show action costs and coverage estimate in Buy-YAPP modal Adds a 'See action costs' disclosure listing the per-action YAPP prices from the contract (post 10, reply 3, like/repost 1) and a dynamic 'covers about N posts, M replies, or K likes' line under the cost on both the input and confirm steps, so buyers know how long a bundle lasts. Co-Authored-By: Claude Fable 5 --- components/token/buy-yapp-modal.tsx | 56 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/components/token/buy-yapp-modal.tsx b/components/token/buy-yapp-modal.tsx index 4c5d7251..bc829e61 100644 --- a/components/token/buy-yapp-modal.tsx +++ b/components/token/buy-yapp-modal.tsx @@ -13,10 +13,19 @@ 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). */ @@ -40,6 +49,7 @@ export function BuyYappModal() { const [creditBalance, setCreditBalance] = useState(null) const [pricePerToken, setPricePerToken] = useState(null) const [loading, setLoading] = useState(false) + const [showCosts, setShowCosts] = useState(false) useEffect(() => { if (isOpen && user) { @@ -57,6 +67,7 @@ export function BuyYappModal() { setAmount('100') setState('input') setError(null) + setShowCosts(false) } }, [isOpen]) @@ -155,9 +166,40 @@ export function BuyYappModal() {

{reason}

)}

- YAPP powers posting, comments, and likes. Buy in bundles of {MIN_YAPP_PURCHASE.toString()}+ — the upfront stake keeps spam out. + 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)) : '—'}
@@ -191,9 +233,14 @@ export function BuyYappModal() { ))}
-
- Cost - {costDashLabel} +
+
+ Cost + {costDashLabel} +
+ {amountBig > BigInt(0) && ( +

{formatCoverage(amountBig)}

+ )}
{error &&

{error}

} @@ -215,6 +262,7 @@ export function BuyYappModal() { Cost {costDashLabel}
+

{formatCoverage(amountBig)}

From c6c0119adb2a2e3564a11cb933d2520ea4d1b716 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 2 Jul 2026 11:40:06 -0500 Subject: [PATCH 09/11] fix: sign YAPP purchases with a CRITICAL key, prompting when login key is HIGH Drive rejects token direct purchases signed with a HIGH auth key (Invalid public key security level HIGH, requires CRITICAL). buyYapp now matches the stored WIF against CRITICAL keys only and returns NEEDS_CRITICAL_KEY when it can't; the Buy-YAPP modal then shows an Authorize Purchase step where the user pastes their CRITICAL key, which signs locally and is never stored. The key is cleared on close, back, success, and any exit from the authorize flow so a retry can never silently reuse it. Freeze/unfreeze authority actions keep their existing CRITICAL|HIGH matching. Co-Authored-By: Claude Fable 5 --- components/token/buy-yapp-modal.tsx | 65 +++++++++++++++++++++-- lib/services/token-service.ts | 80 +++++++++++++++++++++-------- 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/components/token/buy-yapp-modal.tsx b/components/token/buy-yapp-modal.tsx index bc829e61..bca42f93 100644 --- a/components/token/buy-yapp-modal.tsx +++ b/components/token/buy-yapp-modal.tsx @@ -36,7 +36,7 @@ function formatCreditsAsDash(credits: bigint): string { return `${whole.toString()}.${frac.toString().padStart(4, '0')} DASH` } -type ModalState = 'input' | 'confirming' | 'processing' | 'success' | 'error' +type ModalState = 'input' | 'confirming' | 'needKey' | 'processing' | 'success' | 'error' export function BuyYappModal() { const { isOpen, reason, close } = useBuyYappModal() @@ -50,6 +50,9 @@ export function BuyYappModal() { 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) { @@ -68,6 +71,7 @@ export function BuyYappModal() { setState('input') setError(null) setShowCosts(false) + setCriticalKeyWif('') } }, [isOpen]) @@ -106,14 +110,24 @@ export function BuyYappModal() { 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 result = await tokenService.buyYapp(user.identityId, amountBig, costCredits) + 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') } @@ -146,7 +160,7 @@ export function BuyYappModal() { > - {state === 'success' ? 'YAPP Purchased!' : state === 'error' ? 'Purchase Failed' : 'Buy YAPP'} + {state === 'success' ? 'YAPP Purchased!' : state === 'error' ? 'Purchase Failed' : state === 'needKey' ? 'Authorize Purchase' : 'Buy YAPP'} Buy YAPP tokens to post, comment, and like on Yappr. @@ -265,12 +279,55 @@ export function BuyYappModal() {

{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' && (
diff --git a/lib/services/token-service.ts b/lib/services/token-service.ts index 3b41292b..a8603a00 100644 --- a/lib/services/token-service.ts +++ b/lib/services/token-service.ts @@ -10,7 +10,7 @@ import { extractErrorMessage } from '../error-utils'; export interface TokenResult { success: boolean; error?: string; - errorCode?: 'INVALID_KEY' | 'INSUFFICIENT_CREDITS' | 'BELOW_MINIMUM' | 'NOT_AUTHORIZED' | 'NETWORK_ERROR'; + 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. */ @@ -85,16 +85,24 @@ class TokenService { * 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. Requires the buyer's CRITICAL/HIGH auth key. + * 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): Promise { + 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); + const { signer, identityKey } = await this.getAuthSigner(buyerId, { + requireCritical: true, + overrideWif: criticalKeyWif, + }); await sdk.tokens.directPurchase({ dataContractId: new Identifier(YAPPR_CONTRACT_ID), @@ -164,43 +172,61 @@ class TokenService { } /** - * Build an IdentitySigner + matching AUTHENTICATION key for an identity, - * using the private key from secure storage. Mirrors tip-service's transfer-key flow. + * 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) { - const { getPrivateKey } = await import('../secure-storage'); - const wif = getPrivateKey(identityId); + private async getAuthSigner( + identityId: string, + opts?: { requireCritical?: boolean; overrideWif?: string } + ) { + let wif = opts?.overrideWif?.trim(); if (!wif) { - const { promptForAuthKey } = await import('../auth-utils'); - promptForAuthKey(); - throw new Error('Private key not found — please re-authenticate'); + 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 authKey = this.findMatchingAuthKey(wif.trim(), identity.publicKeys); + const allowedLevels = opts?.requireCritical + ? [SecurityLevel.CRITICAL] + : [SecurityLevel.CRITICAL, SecurityLevel.HIGH]; + const authKey = this.findMatchingAuthKey(wif, identity.publicKeys, allowedLevels); if (!authKey) { - throw new Error('No matching AUTHENTICATION key (CRITICAL/HIGH) found for the stored private key'); + throw new Error( + opts?.requireCritical + ? 'critical key required' + : 'No matching AUTHENTICATION key (CRITICAL/HIGH) found for the stored private key' + ); } - return signerService.createSignerFromWasmKey(wif.trim(), authKey); + return signerService.createSignerFromWasmKey(wif, authKey); } - /** Find the AUTHENTICATION key on the identity that matches the provided WIF. */ + /** Find the AUTHENTICATION key at an allowed security level that matches the provided WIF. */ private findMatchingAuthKey( wif: string, - wasmPublicKeys: WasmIdentityPublicKey[] + wasmPublicKeys: WasmIdentityPublicKey[], + allowedLevels: number[] ): WasmIdentityPublicKey | null { const network = (process.env.NEXT_PUBLIC_NETWORK as 'testnet' | 'mainnet') || 'testnet'; - // Token transitions need a CRITICAL or HIGH AUTHENTICATION key. Filter to those - // 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. + // 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 && - (k.securityLevelNumber === SecurityLevel.CRITICAL || k.securityLevelNumber === SecurityLevel.HIGH) + allowedLevels.includes(k.securityLevelNumber) ); if (authKeys.length === 0) return null; @@ -221,6 +247,18 @@ class TokenService { 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' }; } From 12488734a4a2fe387235dc52f484c1a7f2e97d2a Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 2 Jul 2026 18:03:22 -0500 Subject: [PATCH 10/11] fix: resolve review findings on balances, interaction lookups, and tip warnings Reset and stale-guard the sidebar YAPP balance on identity switch; chunk per-user like/repost/bookmark lookups and grouped counts to respect Platform's 100-value in-clause cap; rethrow from identityService.getBalance so fetch failures aren't treated as a zero balance (call sites hardened); return structured announcement-failure detail from sendTip so the tip toast only blames missing YAPP when that was the cause. Co-Authored-By: Claude Fable 5 --- components/layout/sidebar.tsx | 9 ++- components/post/tip-modal.tsx | 9 ++- components/token/yapp-balance-item.tsx | 20 ++++-- lib/services/bookmark-service.ts | 24 ++++--- lib/services/identity-service.ts | 6 +- lib/services/like-service.ts | 30 +++++---- lib/services/pagination-utils.ts | 90 +++++++++++++++----------- lib/services/repost-service.ts | 29 +++++---- lib/services/tip-service.ts | 49 ++++++++++---- 9 files changed, 172 insertions(+), 94 deletions(-) diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 6b28d98d..e9a3f380 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -309,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" diff --git a/components/post/tip-modal.tsx b/components/post/tip-modal.tsx index b8377c5d..13af1e1a 100644 --- a/components/post/tip-modal.tsx +++ b/components/post/tip-modal.tsx @@ -207,9 +207,14 @@ export function TipModal() { // 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. + // letting it vanish silently, and only blame YAPP when that was the cause. if (result.announcementPosted === false) { - toast('Tip sent — but the public tip note couldn’t be posted (needs YAPP).', { icon: '⚠️', duration: 5000 }) + 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 diff --git a/components/token/yapp-balance-item.tsx b/components/token/yapp-balance-item.tsx index d5908ac9..3016f3c7 100644 --- a/components/token/yapp-balance-item.tsx +++ b/components/token/yapp-balance-item.tsx @@ -15,14 +15,26 @@ export function YappBalanceItem() { const { user } = useAuth() const openBuy = useBuyYappModal((s) => s.open) const [yapp, setYapp] = useState(null) + const identityId = user?.identityId useEffect(() => { - if (!user) return - const refresh = () => tokenService.getBalance(user.identityId).then(setYapp).catch(() => setYapp(null)) + // 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 () => window.removeEventListener('yapp-balance-changed', refresh) - }, [user]) + return () => { + cancelled = true + window.removeEventListener('yapp-balance-changed', refresh) + } + }, [identityId]) if (!user) return null 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/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 7938391e..b257114d 100644 --- a/lib/services/like-service.ts +++ b/lib/services/like-service.ts @@ -2,7 +2,7 @@ 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, documentCount, groupedDocumentCount } from './pagination-utils'; +import { paginateFetchAll, documentCount, groupedDocumentCount, chunk, mapLimit, MAX_IN_CLAUSE_VALUES } from './pagination-utils'; import { isInsufficientTokenError } from '../error-utils'; export interface LikeDocument { @@ -186,18 +186,24 @@ class LikeService extends BaseDocumentService { if (postIds.length === 0) return new Set(); try { const sdk = await import('../services/evo-sdk-service').then(m => m.getEvoSdk()); - const response = await sdk.documents.query({ - dataContractId: this.contractId, - documentTypeName: 'like', - where: [['postId', 'in', postIds], ['$ownerId', '==', userId]], - orderBy: [['postId', 'asc'], ['$ownerId', 'asc']], - limit: 100, - }); const liked = new Set(); - for (const doc of normalizeSDKResponse(response)) { - const like = this.transformDocument(doc); - if (like?.postId) liked.add(like.postId); - } + // 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); diff --git a/lib/services/pagination-utils.ts b/lib/services/pagination-utils.ts index 95e8a349..90f98aad 100644 --- a/lib/services/pagination-utils.ts +++ b/lib/services/pagination-utils.ts @@ -35,6 +35,21 @@ 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. @@ -118,48 +133,51 @@ export async function groupedDocumentCount( if (ids.length === 0) return result; ids.forEach((id) => result.set(id, 0)); - const hexToId = new Map(); - ids.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', ids]], - groupBy: [query.groupField], - limit: ids.length, + // 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); }); - 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++; + 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'); + 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; - } 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(ids, 6, fallbackCount); - ids.forEach((id, i) => result.set(id, counts[i])); - return result; - } + return result; } /** diff --git a/lib/services/repost-service.ts b/lib/services/repost-service.ts index ee1b1c48..455f185b 100644 --- a/lib/services/repost-service.ts +++ b/lib/services/repost-service.ts @@ -2,7 +2,7 @@ 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, documentCount, groupedDocumentCount } from './pagination-utils'; +import { paginateFetchAll, documentCount, groupedDocumentCount, chunk, mapLimit, MAX_IN_CLAUSE_VALUES } from './pagination-utils'; import { isInsufficientTokenError } from '../error-utils'; /** A repost of a post — a dedicated `repost` document ({ postId, postOwnerId }). */ @@ -178,18 +178,23 @@ class RepostService { if (postIds.length === 0) return new Set(); try { const sdk = await this.sdk(); - const response = await sdk.documents.query({ - dataContractId: this.contractId, - documentTypeName: this.documentType, - where: [['$ownerId', '==', userId], ['postId', 'in', postIds]], - orderBy: [['$ownerId', 'asc'], ['postId', 'asc']], - limit: 100, - }); const out = new Set(); - for (const doc of normalizeSDKResponse(response)) { - const r = this.map(doc); - if (r) out.add(r.postId); - } + // 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); diff --git a/lib/services/tip-service.ts b/lib/services/tip-service.ts index 7c69ef5e..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 { @@ -18,6 +19,12 @@ export interface TipResult { * 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 @@ -131,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' }; } @@ -254,15 +268,16 @@ class TipService { // 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 announcementPosted = postId + const announcement = postId ? await this.createTipPost(senderId, postId, recipientId, amountCredits, message) - : true; + : { posted: true as const }; return { success: true, // TODO: Return actual transaction hash once SDK exposes it transactionHash: 'confirmed', - announcementPosted, + announcementPosted: announcement.posted, + announcementError: announcement.posted ? undefined : announcement.reason, }; } catch (error) { @@ -278,14 +293,15 @@ class TipService { identityService.clearCache(senderId); // Create tip post (amount is known even if confirmation timed out) - const announcementPosted = postId + const announcement = postId ? await this.createTipPost(senderId, postId, recipientId, amountCredits, message) - : true; + : { posted: true as const }; return { success: true, transactionHash: 'pending-confirmation', - announcementPosted, + announcementPosted: announcement.posted, + announcementError: announcement.posted ? undefined : announcement.reason, }; } @@ -329,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 @@ -342,11 +358,16 @@ class TipService { await replyService.createReply(senderId, content, postId, postOwnerId); logger.info('Tip reply created successfully'); - return true; + 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 false; + return { + posted: false, + reason: isInsufficientTokenError(error) ? 'INSUFFICIENT_YAPP' : 'POST_FAILED', + }; } } From bf695e1422d389df1e3f9cd46ed8bf4a91025408 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 2 Jul 2026 23:21:15 -0500 Subject: [PATCH 11/11] fix: improve buy yapp modal accessibility --- components/token/buy-yapp-modal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/token/buy-yapp-modal.tsx b/components/token/buy-yapp-modal.tsx index bca42f93..78e69e1e 100644 --- a/components/token/buy-yapp-modal.tsx +++ b/components/token/buy-yapp-modal.tsx @@ -57,7 +57,7 @@ export function BuyYappModal() { useEffect(() => { if (isOpen && user) { setLoading(true) - Promise.all([ + 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)), @@ -168,6 +168,7 @@ export function BuyYappModal() {
- +