mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-06 23:03:07 +00:00
feat: add operator ordinal validation (#23)
* chore: add ordiscan * feat: add ordinal check * chore: fix CSS + useMemo * chore: show inscription ID on /profile
This commit is contained in:
parent
849a5b41be
commit
243403652e
19
package-lock.json
generated
19
package-lock.json
generated
@ -52,6 +52,7 @@
|
|||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"ordiscan": "^1.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@ -12609,6 +12610,18 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ordiscan": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ordiscan/-/ordiscan-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-IV8yayKGIRtfkI3rQ1gu+aKQ6UmNHXB910qUuWrraCcuk6U/YkE+6X8Bh+jW2P8L8/lOfYA6DLVeKCVPkj8c6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ox": {
|
"node_modules/ox": {
|
||||||
"version": "0.6.9",
|
"version": "0.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz",
|
||||||
@ -15898,9 +15911,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.23.8",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"ordiscan": "^1.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@ -127,7 +127,7 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ShareButton
|
<ShareButton
|
||||||
size='sm'
|
size="sm"
|
||||||
url={`${window.location.origin}/cell/${cell.id}`}
|
url={`${window.location.origin}/cell/${cell.id}`}
|
||||||
title={cell.name}
|
title={cell.name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -129,9 +129,12 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShareButton
|
<ShareButton
|
||||||
size='sm'
|
size="sm"
|
||||||
url={`${window.location.origin}/post/${postId}#comment-${comment.id}`}
|
url={`${window.location.origin}/post/${postId}#comment-${comment.id}`}
|
||||||
title={comment.content.substring(0, 50) + (comment.content.length > 50 ? '...' : '')}
|
title={
|
||||||
|
comment.content.substring(0, 50) +
|
||||||
|
(comment.content.length > 50 ? '...' : '')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
isBookmarked={isBookmarked}
|
isBookmarked={isBookmarked}
|
||||||
|
|||||||
@ -118,7 +118,11 @@ export function CreateCellDialog({
|
|||||||
<DialogTitle className="text-glow">Create New Cell</DialogTitle>
|
<DialogTitle className="text-glow">Create New Cell</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} onKeyDown={handleKeyDown} className="space-y-4">
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="title"
|
name="title"
|
||||||
|
|||||||
@ -293,7 +293,7 @@ const Header = () => {
|
|||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={e => e.preventDefault()}
|
||||||
className="flex items-center space-x-2 text-orange-400 focus:text-orange-400"
|
className="flex items-center space-x-2 text-orange-400 focus:text-orange-400"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@ -306,7 +306,8 @@ const Header = () => {
|
|||||||
Clear Local Database
|
Clear Local Database
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-cyber-neutral">
|
<AlertDialogDescription className="text-cyber-neutral">
|
||||||
This will permanently delete all locally stored data including:
|
This will permanently delete all locally stored
|
||||||
|
data including:
|
||||||
<br />• Posts and comments
|
<br />• Posts and comments
|
||||||
<br />• User identities and preferences
|
<br />• User identities and preferences
|
||||||
<br />• Bookmarks and votes
|
<br />• Bookmarks and votes
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowUp, ArrowDown, MessageSquare, Clipboard } from 'lucide-react';
|
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Post } from '@/types/forum';
|
import { Post } from '@/types/forum';
|
||||||
import {
|
import {
|
||||||
@ -70,7 +70,6 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
|||||||
await toggleBookmark();
|
await toggleBookmark();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="thread-card mb-2">
|
<div className="thread-card mb-2">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@ -180,7 +179,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ShareButton
|
<ShareButton
|
||||||
size='sm'
|
size="sm"
|
||||||
url={`${window.location.origin}/post/${post.id}`}
|
url={`${window.location.origin}/post/${post.id}`}
|
||||||
title={post.title}
|
title={post.title}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -255,7 +255,7 @@ const PostDetail = () => {
|
|||||||
showText={true}
|
showText={true}
|
||||||
/>
|
/>
|
||||||
<ShareButton
|
<ShareButton
|
||||||
size='lg'
|
size="lg"
|
||||||
url={`${window.location.origin}/post/${post.id}`}
|
url={`${window.location.origin}/post/${post.id}`}
|
||||||
title={post.title}
|
title={post.title}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
usePermissions,
|
usePermissions,
|
||||||
useUserVotes,
|
useUserVotes,
|
||||||
useAuth,
|
useAuth,
|
||||||
usePostComments,
|
useForumData,
|
||||||
} from '@/hooks';
|
} from '@/hooks';
|
||||||
import { EVerificationStatus } from '@/types/identity';
|
import { EVerificationStatus } from '@/types/identity';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -56,6 +56,7 @@ const PostList = () => {
|
|||||||
const { canPost, canVote, canModerate } = usePermissions();
|
const { canPost, canVote, canModerate } = usePermissions();
|
||||||
const userVotes = useUserVotes();
|
const userVotes = useUserVotes();
|
||||||
const { currentUser, verificationStatus } = useAuth();
|
const { currentUser, verificationStatus } = useAuth();
|
||||||
|
const { commentsByPost } = useForumData();
|
||||||
|
|
||||||
const [newPostTitle, setNewPostTitle] = useState('');
|
const [newPostTitle, setNewPostTitle] = useState('');
|
||||||
const [newPostContent, setNewPostContent] = useState('');
|
const [newPostContent, setNewPostContent] = useState('');
|
||||||
@ -339,7 +340,7 @@ const PostList = () => {
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
<MessageSquare className="inline w-3 h-3 mr-1" />
|
<MessageSquare className="inline w-3 h-3 mr-1" />
|
||||||
{usePostComments(post.id).totalCount} comments
|
{commentsByPost[post.id]?.length || 0} comments
|
||||||
</span>
|
</span>
|
||||||
<ShareButton
|
<ShareButton
|
||||||
url={`${window.location.origin}/post/${post.id}`}
|
url={`${window.location.origin}/post/${post.id}`}
|
||||||
|
|||||||
@ -15,8 +15,6 @@ interface ShareButtonProps {
|
|||||||
|
|
||||||
export function ShareButton({
|
export function ShareButton({
|
||||||
url,
|
url,
|
||||||
title,
|
|
||||||
description = 'Check out this post',
|
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
variant = 'ghost',
|
variant = 'ghost',
|
||||||
className,
|
className,
|
||||||
@ -73,9 +71,7 @@ export function ShareButton({
|
|||||||
title="Copy link"
|
title="Copy link"
|
||||||
>
|
>
|
||||||
<Share2 size={iconSize[size]} />
|
<Share2 size={iconSize[size]} />
|
||||||
{showText && (
|
{showText && <span className="ml-2 text-xs">Share</span>}
|
||||||
<span className="ml-2 text-xs">Share</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -55,21 +55,9 @@ export function WalletWizard({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Business logic: determine step status based on current wizard step
|
// Consolidated step status logic
|
||||||
const getStepStatus = (step: WizardStep) => {
|
const getStepStatus = (step: WizardStep) => {
|
||||||
if (step < currentStep) {
|
// Check actual completion status first
|
||||||
return 'complete';
|
|
||||||
} else if (step === currentStep) {
|
|
||||||
return 'current';
|
|
||||||
} else {
|
|
||||||
return 'disabled';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStepIcon = (step: WizardStep) => {
|
|
||||||
const status = getStepStatus(step);
|
|
||||||
|
|
||||||
// Check if step is actually completed based on auth state
|
|
||||||
const isActuallyComplete = (step: WizardStep): boolean => {
|
const isActuallyComplete = (step: WizardStep): boolean => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 1:
|
case 1:
|
||||||
@ -83,7 +71,19 @@ export function WalletWizard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'complete' || isActuallyComplete(step)) {
|
if (isActuallyComplete(step)) {
|
||||||
|
return 'complete';
|
||||||
|
} else if (step === currentStep) {
|
||||||
|
return 'current';
|
||||||
|
} else {
|
||||||
|
return 'disabled';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepIcon = (step: WizardStep) => {
|
||||||
|
const status = getStepStatus(step);
|
||||||
|
|
||||||
|
if (status === 'complete') {
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
} else if (status === 'current') {
|
} else if (status === 'current') {
|
||||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||||
@ -107,7 +107,7 @@ export function WalletWizard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-md border-neutral-800 bg-black text-white">
|
<DialogContent className="sm:max-w-lg border-neutral-800 bg-black text-white">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">Setup Your Account</DialogTitle>
|
<DialogTitle className="text-xl">Setup Your Account</DialogTitle>
|
||||||
<DialogDescription className="text-neutral-400">
|
<DialogDescription className="text-neutral-400">
|
||||||
@ -116,21 +116,16 @@ export function WalletWizard({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
{/* Progress Indicator */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-center mb-8">
|
||||||
{[1, 2, 3].map(step => (
|
{[1, 2, 3].map((step, index) => (
|
||||||
<div key={step} className="flex items-center">
|
<div key={step} className="flex items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{renderStepIcon(step as WizardStep)}
|
{renderStepIcon(step as WizardStep)}
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`text-sm font-medium ${
|
||||||
getStepStatus(step as WizardStep) === 'current'
|
getStepStatus(step as WizardStep) === 'current'
|
||||||
? 'text-blue-500 font-medium'
|
? 'text-blue-500'
|
||||||
: getStepStatus(step as WizardStep) === 'complete' ||
|
: getStepStatus(step as WizardStep) === 'complete'
|
||||||
(step === 1 && isAuthenticated) ||
|
|
||||||
(step === 2 &&
|
|
||||||
verificationStatus !==
|
|
||||||
EVerificationStatus.WALLET_UNCONNECTED) ||
|
|
||||||
(step === 3 && delegationStatus.isValid)
|
|
||||||
? 'text-green-500'
|
? 'text-green-500'
|
||||||
: 'text-gray-400'
|
: 'text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
@ -138,14 +133,10 @@ export function WalletWizard({
|
|||||||
{getStepTitle(step as WizardStep)}
|
{getStepTitle(step as WizardStep)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{step < 3 && (
|
{index < 2 && (
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-px mx-2 ${
|
className={`w-16 h-px mx-4 ${
|
||||||
getStepStatus(step as WizardStep) === 'complete' ||
|
getStepStatus(step as WizardStep) === 'complete'
|
||||||
(step === 1 && isAuthenticated) ||
|
|
||||||
(step === 2 &&
|
|
||||||
verificationStatus !==
|
|
||||||
EVerificationStatus.WALLET_UNCONNECTED)
|
|
||||||
? 'bg-green-500'
|
? 'bg-green-500'
|
||||||
: 'bg-gray-600'
|
: 'bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
@ -155,8 +146,8 @@ export function WalletWizard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step Content - Fixed height container */}
|
{/* Step Content - Flexible height container */}
|
||||||
<div className="h-[400px] flex flex-col">
|
<div className="min-h-[400px] flex flex-col">
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<WalletConnectionStep
|
<WalletConnectionStep
|
||||||
onComplete={() => handleStepComplete(1)}
|
onComplete={() => handleStepComplete(1)}
|
||||||
@ -185,7 +176,7 @@ export function WalletWizard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-neutral-700">
|
<div className="flex justify-between items-center pt-6 mt-4 border-t border-neutral-700">
|
||||||
<p className="text-xs text-neutral-500">Step {currentStep} of 3</p>
|
<p className="text-xs text-neutral-500">Step {currentStep} of 3</p>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<Button
|
<Button
|
||||||
@ -193,7 +184,7 @@ export function WalletWizard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentStep((currentStep - 1) as WizardStep)}
|
onClick={() => setCurrentStep((currentStep - 1) as WizardStep)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="text-neutral-400 hover:text-white"
|
className="text-neutral-400 hover:text-white hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
} from '@/lib/delegation';
|
} from '@/lib/delegation';
|
||||||
import { localDatabase } from '@/lib/database/LocalDatabase';
|
import { localDatabase } from '@/lib/database/LocalDatabase';
|
||||||
import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react';
|
import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react';
|
||||||
|
import { MessageService } from '@/lib/services/MessageService';
|
||||||
|
import { UserIdentityService } from '@/lib/services/UserIdentityService';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
@ -56,6 +58,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Create manager instances
|
// Create manager instances
|
||||||
const delegationManager = useMemo(() => new DelegationManager(), []);
|
const delegationManager = useMemo(() => new DelegationManager(), []);
|
||||||
|
const messageService = useMemo(
|
||||||
|
() => new MessageService(delegationManager),
|
||||||
|
[delegationManager]
|
||||||
|
);
|
||||||
|
const userIdentityService = useMemo(
|
||||||
|
() => new UserIdentityService(messageService),
|
||||||
|
[messageService]
|
||||||
|
);
|
||||||
|
|
||||||
// Create wallet manager when we have all dependencies
|
// Create wallet manager when we have all dependencies
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -89,51 +99,44 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function for ownership verification
|
// Helper function for ownership verification (via UserIdentityService)
|
||||||
const verifyUserOwnership = async (user: User): Promise<User> => {
|
const verifyUserOwnership = async (user: User): Promise<User> => {
|
||||||
if (user.walletType === 'bitcoin') {
|
|
||||||
// TODO: revert when the API is ready
|
|
||||||
// const response = await ordinalApi.getOperatorDetails(user.address);
|
|
||||||
// const hasOperators = response.has_operators;
|
|
||||||
const hasOperators = true;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
ordinalDetails: hasOperators
|
|
||||||
? { ordinalId: 'mock', ordinalDetails: 'Mock ordinal for testing' }
|
|
||||||
: undefined,
|
|
||||||
verificationStatus: hasOperators
|
|
||||||
? EVerificationStatus.ENS_ORDINAL_VERIFIED
|
|
||||||
: EVerificationStatus.WALLET_CONNECTED,
|
|
||||||
lastChecked: Date.now(),
|
|
||||||
};
|
|
||||||
} else if (user.walletType === 'ethereum') {
|
|
||||||
try {
|
try {
|
||||||
const walletInfo = WalletManager.hasInstance()
|
// Force fresh resolution to ensure API call happens during verification
|
||||||
? await WalletManager.getInstance().getWalletInfo()
|
const identity = await userIdentityService.getUserIdentityFresh(
|
||||||
: null;
|
user.address
|
||||||
const hasENS = !!walletInfo?.ensName;
|
);
|
||||||
const ensName = walletInfo?.ensName;
|
if (!identity) {
|
||||||
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
ensDetails: hasENS && ensName ? { ensName } : undefined,
|
|
||||||
verificationStatus: hasENS
|
|
||||||
? EVerificationStatus.ENS_ORDINAL_VERIFIED
|
|
||||||
: EVerificationStatus.WALLET_CONNECTED,
|
|
||||||
lastChecked: Date.now(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying ENS ownership:', error);
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
ensDetails: undefined,
|
ensDetails: undefined,
|
||||||
|
ordinalDetails: undefined,
|
||||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||||
lastChecked: Date.now(),
|
lastChecked: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw new Error('Unknown wallet type');
|
return {
|
||||||
|
...user,
|
||||||
|
ensDetails: identity.ensName
|
||||||
|
? { ensName: identity.ensName }
|
||||||
|
: undefined,
|
||||||
|
ordinalDetails: identity.ordinalDetails,
|
||||||
|
verificationStatus: identity.verificationStatus,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Error verifying ownership via UserIdentityService:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
ensDetails: undefined,
|
||||||
|
ordinalDetails: undefined,
|
||||||
|
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -212,12 +215,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||||
);
|
);
|
||||||
await saveUser(updatedUser);
|
await saveUser(updatedUser);
|
||||||
await localDatabase.upsertUserIdentity(updatedUser.address, {
|
await localDatabase.upsertUserIdentity(
|
||||||
|
updatedUser.address,
|
||||||
|
{
|
||||||
ensName: walletInfo.ensName,
|
ensName: walletInfo.ensName,
|
||||||
verificationStatus:
|
verificationStatus:
|
||||||
EVerificationStatus.ENS_ORDINAL_VERIFIED,
|
EVerificationStatus.ENS_ORDINAL_VERIFIED,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setCurrentUser(newUser);
|
setCurrentUser(newUser);
|
||||||
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
|
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
|
||||||
|
|||||||
@ -632,7 +632,8 @@ export class LocalDatabase {
|
|||||||
record: Partial<UserIdentityCache[string]> & { lastUpdated?: number }
|
record: Partial<UserIdentityCache[string]> & { lastUpdated?: number }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing: UserIdentityCache[string] =
|
const existing: UserIdentityCache[string] =
|
||||||
this.cache.userIdentities[address] || {
|
this.cache.userIdentities[address] ||
|
||||||
|
({
|
||||||
ensName: undefined,
|
ensName: undefined,
|
||||||
ordinalDetails: undefined,
|
ordinalDetails: undefined,
|
||||||
callSign: undefined,
|
callSign: undefined,
|
||||||
@ -644,12 +645,15 @@ export class LocalDatabase {
|
|||||||
// Casting below ensures the object satisfies the interface at compile time.
|
// Casting below ensures the object satisfies the interface at compile time.
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
||||||
} as unknown as UserIdentityCache[string];
|
} as unknown as UserIdentityCache[string]);
|
||||||
|
|
||||||
const merged: UserIdentityCache[string] = {
|
const merged: UserIdentityCache[string] = {
|
||||||
...existing,
|
...existing,
|
||||||
...record,
|
...record,
|
||||||
lastUpdated: Math.max(existing.lastUpdated ?? 0, record.lastUpdated ?? Date.now()),
|
lastUpdated: Math.max(
|
||||||
|
existing.lastUpdated ?? 0,
|
||||||
|
record.lastUpdated ?? Date.now()
|
||||||
|
),
|
||||||
} as UserIdentityCache[string];
|
} as UserIdentityCache[string];
|
||||||
|
|
||||||
this.cache.userIdentities[address] = merged;
|
this.cache.userIdentities[address] = merged;
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { OrdinalApiResponse } from './types';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://dashboard.logos.co/api/operators/wallet';
|
|
||||||
|
|
||||||
export class OrdinalAPI {
|
|
||||||
/**
|
|
||||||
* Fetches Ordinal operator details for a given Bitcoin address.
|
|
||||||
* @param address - The Bitcoin address to query.
|
|
||||||
* @returns A promise that resolves with the API response.
|
|
||||||
*/
|
|
||||||
async getOperatorDetails(address: string): Promise<OrdinalApiResponse> {
|
|
||||||
if (import.meta.env.VITE_OPCHAN_MOCK_ORDINAL_CHECK === 'true') {
|
|
||||||
console.log(
|
|
||||||
`[DEV] Bypassing ordinal verification for address: ${address}`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
has_operators: true,
|
|
||||||
error_message: '',
|
|
||||||
data: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${BASE_URL}/${address}/detail/`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text().catch(() => '');
|
|
||||||
throw new Error(
|
|
||||||
`HTTP error! status: ${response.status}, message: ${errorBody || response.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: OrdinalApiResponse = await response.json();
|
|
||||||
|
|
||||||
if (data.error_message) {
|
|
||||||
console.warn(
|
|
||||||
`API returned an error message for address ${address}: ${data.error_message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to fetch ordinal details for address ${address}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
export interface OrdinalDetail {
|
|
||||||
name: string;
|
|
||||||
archetype_name: string;
|
|
||||||
comp: string;
|
|
||||||
background: string;
|
|
||||||
skin: string;
|
|
||||||
helmet: string;
|
|
||||||
jacket: string;
|
|
||||||
image_200_url: string;
|
|
||||||
image_200_jpeg_url: string;
|
|
||||||
image_400_url: string;
|
|
||||||
image_400_jpeg_url: string;
|
|
||||||
image_1024_url: string;
|
|
||||||
image_1024_jpeg_url: string;
|
|
||||||
image_2048_url: string;
|
|
||||||
image_2048_jpeg_url: string;
|
|
||||||
image_pixalated_url: string;
|
|
||||||
mp4_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrdinalApiResponse {
|
|
||||||
has_operators: boolean;
|
|
||||||
error_message: string;
|
|
||||||
data: OrdinalDetail[];
|
|
||||||
}
|
|
||||||
47
src/lib/services/Ordinals.ts
Normal file
47
src/lib/services/Ordinals.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Ordiscan, Inscription } from 'ordiscan';
|
||||||
|
const API_KEY = import.meta.env.VITE_ORDISCAN_API;
|
||||||
|
|
||||||
|
class Ordinals {
|
||||||
|
private static instance: Ordinals | null = null;
|
||||||
|
private ordiscan: Ordiscan;
|
||||||
|
private readonly PARENT_INSCRIPTION_ID =
|
||||||
|
'add60add0325f7c82e80d4852a8b8d5c46dbde4317e76fe4def2e718dd84b87ci0';
|
||||||
|
|
||||||
|
private constructor(ordiscan: Ordiscan) {
|
||||||
|
this.ordiscan = ordiscan;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): Ordinals {
|
||||||
|
if (!Ordinals.instance) {
|
||||||
|
Ordinals.instance = new Ordinals(new Ordiscan(API_KEY));
|
||||||
|
}
|
||||||
|
return Ordinals.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Ordinal details for a Bitcoin address
|
||||||
|
*/
|
||||||
|
async getOrdinalDetails(address: string): Promise<Inscription[] | null> {
|
||||||
|
const inscriptions = await this.ordiscan.address.getInscriptions({
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
if (inscriptions.length > 0) {
|
||||||
|
if (
|
||||||
|
inscriptions.some(
|
||||||
|
inscription =>
|
||||||
|
inscription.parent_inscription_id === this.PARENT_INSCRIPTION_ID
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return inscriptions.filter(
|
||||||
|
inscription =>
|
||||||
|
inscription.parent_inscription_id === this.PARENT_INSCRIPTION_ID
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ordinals = Ordinals.getInstance();
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
import { MessageService } from './MessageService';
|
import { MessageService } from './MessageService';
|
||||||
import messageManager from '@/lib/waku';
|
import messageManager from '@/lib/waku';
|
||||||
import { localDatabase } from '@/lib/database/LocalDatabase';
|
import { localDatabase } from '@/lib/database/LocalDatabase';
|
||||||
|
import { WalletManager } from '@/lib/wallet';
|
||||||
|
|
||||||
export interface UserIdentity {
|
export interface UserIdentity {
|
||||||
address: string;
|
address: string;
|
||||||
@ -155,6 +156,29 @@ export class UserIdentityService {
|
|||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a fresh identity resolution bypassing caches and LocalDatabase.
|
||||||
|
* Useful for explicit verification flows where we must hit upstream resolvers.
|
||||||
|
*/
|
||||||
|
async getUserIdentityFresh(address: string): Promise<UserIdentity | null> {
|
||||||
|
if (import.meta.env?.DEV) {
|
||||||
|
console.debug('UserIdentityService: fresh resolve requested');
|
||||||
|
}
|
||||||
|
const identity = await this.resolveUserIdentity(address);
|
||||||
|
if (identity) {
|
||||||
|
// Update in-memory cache to reflect the fresh result
|
||||||
|
this.userIdentityCache[address] = {
|
||||||
|
ensName: identity.ensName,
|
||||||
|
ordinalDetails: identity.ordinalDetails,
|
||||||
|
callSign: identity.callSign,
|
||||||
|
displayPreference: identity.displayPreference,
|
||||||
|
lastUpdated: identity.lastUpdated,
|
||||||
|
verificationStatus: identity.verificationStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all cached user identities
|
* Get all cached user identities
|
||||||
*/
|
*/
|
||||||
@ -329,8 +353,19 @@ export class UserIdentityService {
|
|||||||
address: string
|
address: string
|
||||||
): Promise<{ ordinalId: string; ordinalDetails: string } | null> {
|
): Promise<{ ordinalId: string; ordinalDetails: string } | null> {
|
||||||
try {
|
try {
|
||||||
//TODO: add Ordinal API call
|
if (address.startsWith('0x')) {
|
||||||
console.log('resolveOrdinalDetails', address);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inscriptions = await WalletManager.resolveOperatorOrdinals(address);
|
||||||
|
if (Array.isArray(inscriptions) && inscriptions.length > 0) {
|
||||||
|
const first = inscriptions[0]!;
|
||||||
|
return {
|
||||||
|
ordinalId: first.inscription_id,
|
||||||
|
ordinalDetails:
|
||||||
|
first.parent_inscription_id || 'Operator badge present',
|
||||||
|
};
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to resolve Ordinal details:', error);
|
console.error('Failed to resolve Ordinal details:', error);
|
||||||
@ -375,12 +410,22 @@ export class UserIdentityService {
|
|||||||
*/
|
*/
|
||||||
private mapVerificationStatus(status: string): EVerificationStatus {
|
private mapVerificationStatus(status: string): EVerificationStatus {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
// Legacy message-cache statuses
|
||||||
case 'verified-basic':
|
case 'verified-basic':
|
||||||
return EVerificationStatus.WALLET_CONNECTED;
|
return EVerificationStatus.WALLET_CONNECTED;
|
||||||
case 'verified-owner':
|
case 'verified-owner':
|
||||||
return EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
return EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||||
case 'verifying':
|
case 'verifying':
|
||||||
return EVerificationStatus.WALLET_CONNECTED; // Temporary state during verification
|
return EVerificationStatus.WALLET_CONNECTED; // Temporary state during verification
|
||||||
|
|
||||||
|
// Enum string values persisted in LocalDatabase
|
||||||
|
case EVerificationStatus.WALLET_UNCONNECTED:
|
||||||
|
return EVerificationStatus.WALLET_UNCONNECTED;
|
||||||
|
case EVerificationStatus.WALLET_CONNECTED:
|
||||||
|
return EVerificationStatus.WALLET_CONNECTED;
|
||||||
|
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||||
|
return EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return EVerificationStatus.WALLET_UNCONNECTED;
|
return EVerificationStatus.WALLET_UNCONNECTED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { UseAppKitAccountReturn } from '@reown/appkit/react';
|
import { UseAppKitAccountReturn } from '@reown/appkit/react';
|
||||||
import { AppKit } from '@reown/appkit';
|
import { AppKit } from '@reown/appkit';
|
||||||
|
import { ordinals } from '@/lib/services/Ordinals';
|
||||||
import {
|
import {
|
||||||
getEnsName,
|
getEnsName,
|
||||||
verifyMessage as verifyEthereumMessage,
|
verifyMessage as verifyEthereumMessage,
|
||||||
@ -8,6 +9,7 @@ import { ChainNamespace } from '@reown/appkit-common';
|
|||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { Provider } from '@reown/appkit-controllers';
|
import { Provider } from '@reown/appkit-controllers';
|
||||||
import { WalletInfo, ActiveWallet } from './types';
|
import { WalletInfo, ActiveWallet } from './types';
|
||||||
|
import { Inscription } from 'ordiscan';
|
||||||
export class WalletManager {
|
export class WalletManager {
|
||||||
private static instance: WalletManager | null = null;
|
private static instance: WalletManager | null = null;
|
||||||
|
|
||||||
@ -95,6 +97,20 @@ export class WalletManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Ordinal details for a Bitcoin address
|
||||||
|
*/
|
||||||
|
static async resolveOperatorOrdinals(
|
||||||
|
address: string
|
||||||
|
): Promise<Inscription[] | null> {
|
||||||
|
try {
|
||||||
|
return await ordinals.getOrdinalDetails(address);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve Ordinal details:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently active wallet
|
* Get the currently active wallet
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,7 +15,9 @@ export default function DebugPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Subscribe to inbound messages from reliable channel
|
// Subscribe to inbound messages from reliable channel
|
||||||
unsubscribeRef.current = messageManager.onMessageReceived(msg => {
|
unsubscribeRef.current = messageManager.onMessageReceived(msg => {
|
||||||
setMessages(prev => [{ receivedAt: Date.now(), message: msg }, ...prev].slice(0, 500));
|
setMessages(prev =>
|
||||||
|
[{ receivedAt: Date.now(), message: msg }, ...prev].slice(0, 500)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -47,7 +49,9 @@ export default function DebugPage() {
|
|||||||
Total received: {messages.length}
|
Total received: {messages.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div
|
||||||
|
style={{ marginTop: 12, display: 'flex', gap: 12, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
{Object.values(MessageType).map(t => (
|
{Object.values(MessageType).map(t => (
|
||||||
<div
|
<div
|
||||||
key={t}
|
key={t}
|
||||||
@ -59,13 +63,22 @@ export default function DebugPage() {
|
|||||||
background: 'rgba(51,65,85,0.2)',
|
background: 'rgba(51,65,85,0.2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ textTransform: 'capitalize' }}>{t}</strong>: {typeCounts[t] || 0}
|
<strong style={{ textTransform: 'capitalize' }}>{t}</strong>:{' '}
|
||||||
|
{typeCounts[t] || 0}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 16, borderTop: '1px solid #334155', paddingTop: 12 }}>
|
<div
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Recent messages</div>
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
borderTop: '1px solid #334155',
|
||||||
|
paddingTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>
|
||||||
|
Recent messages
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -81,7 +94,9 @@ export default function DebugPage() {
|
|||||||
{messages.map(m => (
|
{messages.map(m => (
|
||||||
<Fragment key={`${m.message.id}:${m.receivedAt}`}>
|
<Fragment key={`${m.message.id}:${m.receivedAt}`}>
|
||||||
<div style={{ color: '#e5e7eb' }}>{formatTs(m.receivedAt)}</div>
|
<div style={{ color: '#e5e7eb' }}>{formatTs(m.receivedAt)}</div>
|
||||||
<div style={{ textTransform: 'capitalize', color: '#e5e7eb' }}>{m.message.type}</div>
|
<div style={{ textTransform: 'capitalize', color: '#e5e7eb' }}>
|
||||||
|
{m.message.type}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -91,9 +106,12 @@ export default function DebugPage() {
|
|||||||
}}
|
}}
|
||||||
title={`${m.message.id} — ${m.message.author}`}
|
title={`${m.message.id} — ${m.message.author}`}
|
||||||
>
|
>
|
||||||
{m.message.id} — <span style={{ color: '#94a3b8' }}>{m.message.author}</span>
|
{m.message.id} —{' '}
|
||||||
|
<span style={{ color: '#94a3b8' }}>{m.message.author}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#e5e7eb' }}>
|
||||||
|
{formatTs(m.message.timestamp)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#e5e7eb' }}>{formatTs(m.message.timestamp)}</div>
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -277,7 +277,9 @@ export default function ProfilePage() {
|
|||||||
{userInfo.displayName}
|
{userInfo.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-cyber-neutral">
|
<div className="text-sm text-cyber-neutral">
|
||||||
{currentUser.ensDetails?.ensName || 'No ENS name'}
|
{userInfo.ordinalDetails || currentUser.ordinalDetails?.ordinalDetails
|
||||||
|
? `Ordinal: ${userInfo.ordinalDetails || currentUser.ordinalDetails?.ordinalDetails}`
|
||||||
|
: currentUser.ensDetails?.ensName || 'No ENS name'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
{getVerificationIcon()}
|
{getVerificationIcon()}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user