mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +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",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"ordiscan": "^1.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@ -12609,6 +12610,18 @@
|
||||
"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": {
|
||||
"version": "0.6.9",
|
||||
"resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz",
|
||||
@ -15898,9 +15911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.23.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
||||
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"ordiscan": "^1.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@ -127,7 +127,7 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
|
||||
</span>
|
||||
</div>
|
||||
<ShareButton
|
||||
size='sm'
|
||||
size="sm"
|
||||
url={`${window.location.origin}/cell/${cell.id}`}
|
||||
title={cell.name}
|
||||
/>
|
||||
|
||||
@ -129,9 +129,12 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareButton
|
||||
size='sm'
|
||||
size="sm"
|
||||
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
|
||||
isBookmarked={isBookmarked}
|
||||
|
||||
@ -118,7 +118,11 @@ export function CreateCellDialog({
|
||||
<DialogTitle className="text-glow">Create New Cell</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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
|
||||
control={form.control}
|
||||
name="title"
|
||||
|
||||
@ -293,7 +293,7 @@ const Header = () => {
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onSelect={e => e.preventDefault()}
|
||||
className="flex items-center space-x-2 text-orange-400 focus:text-orange-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@ -306,7 +306,8 @@ const Header = () => {
|
||||
Clear Local Database
|
||||
</AlertDialogTitle>
|
||||
<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 />• User identities and preferences
|
||||
<br />• Bookmarks and votes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { Post } from '@/types/forum';
|
||||
import {
|
||||
@ -70,7 +70,6 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
await toggleBookmark();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="thread-card mb-2">
|
||||
<div className="flex">
|
||||
@ -180,7 +179,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
</span>
|
||||
)}
|
||||
<ShareButton
|
||||
size='sm'
|
||||
size="sm"
|
||||
url={`${window.location.origin}/post/${post.id}`}
|
||||
title={post.title}
|
||||
/>
|
||||
|
||||
@ -255,7 +255,7 @@ const PostDetail = () => {
|
||||
showText={true}
|
||||
/>
|
||||
<ShareButton
|
||||
size='lg'
|
||||
size="lg"
|
||||
url={`${window.location.origin}/post/${post.id}`}
|
||||
title={post.title}
|
||||
/>
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
usePermissions,
|
||||
useUserVotes,
|
||||
useAuth,
|
||||
usePostComments,
|
||||
useForumData,
|
||||
} from '@/hooks';
|
||||
import { EVerificationStatus } from '@/types/identity';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -56,6 +56,7 @@ const PostList = () => {
|
||||
const { canPost, canVote, canModerate } = usePermissions();
|
||||
const userVotes = useUserVotes();
|
||||
const { currentUser, verificationStatus } = useAuth();
|
||||
const { commentsByPost } = useForumData();
|
||||
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
@ -339,7 +340,7 @@ const PostList = () => {
|
||||
<span>•</span>
|
||||
<span>
|
||||
<MessageSquare className="inline w-3 h-3 mr-1" />
|
||||
{usePostComments(post.id).totalCount} comments
|
||||
{commentsByPost[post.id]?.length || 0} comments
|
||||
</span>
|
||||
<ShareButton
|
||||
url={`${window.location.origin}/post/${post.id}`}
|
||||
|
||||
@ -15,8 +15,6 @@ interface ShareButtonProps {
|
||||
|
||||
export function ShareButton({
|
||||
url,
|
||||
title,
|
||||
description = 'Check out this post',
|
||||
size = 'sm',
|
||||
variant = 'ghost',
|
||||
className,
|
||||
@ -73,9 +71,7 @@ export function ShareButton({
|
||||
title="Copy link"
|
||||
>
|
||||
<Share2 size={iconSize[size]} />
|
||||
{showText && (
|
||||
<span className="ml-2 text-xs">Share</span>
|
||||
)}
|
||||
{showText && <span className="ml-2 text-xs">Share</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,21 +55,9 @@ export function WalletWizard({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Business logic: determine step status based on current wizard step
|
||||
// Consolidated step status logic
|
||||
const getStepStatus = (step: WizardStep) => {
|
||||
if (step < currentStep) {
|
||||
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
|
||||
// Check actual completion status first
|
||||
const isActuallyComplete = (step: WizardStep): boolean => {
|
||||
switch (step) {
|
||||
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" />;
|
||||
} else if (status === 'current') {
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
@ -107,7 +107,7 @@ export function WalletWizard({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle className="text-xl">Setup Your Account</DialogTitle>
|
||||
<DialogDescription className="text-neutral-400">
|
||||
@ -116,21 +116,16 @@ export function WalletWizard({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{[1, 2, 3].map(step => (
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{[1, 2, 3].map((step, index) => (
|
||||
<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)}
|
||||
<span
|
||||
className={`text-sm ${
|
||||
className={`text-sm font-medium ${
|
||||
getStepStatus(step as WizardStep) === 'current'
|
||||
? 'text-blue-500 font-medium'
|
||||
: getStepStatus(step as WizardStep) === 'complete' ||
|
||||
(step === 1 && isAuthenticated) ||
|
||||
(step === 2 &&
|
||||
verificationStatus !==
|
||||
EVerificationStatus.WALLET_UNCONNECTED) ||
|
||||
(step === 3 && delegationStatus.isValid)
|
||||
? 'text-blue-500'
|
||||
: getStepStatus(step as WizardStep) === 'complete'
|
||||
? 'text-green-500'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
@ -138,14 +133,10 @@ export function WalletWizard({
|
||||
{getStepTitle(step as WizardStep)}
|
||||
</span>
|
||||
</div>
|
||||
{step < 3 && (
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`w-8 h-px mx-2 ${
|
||||
getStepStatus(step as WizardStep) === 'complete' ||
|
||||
(step === 1 && isAuthenticated) ||
|
||||
(step === 2 &&
|
||||
verificationStatus !==
|
||||
EVerificationStatus.WALLET_UNCONNECTED)
|
||||
className={`w-16 h-px mx-4 ${
|
||||
getStepStatus(step as WizardStep) === 'complete'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-600'
|
||||
}`}
|
||||
@ -155,8 +146,8 @@ export function WalletWizard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content - Fixed height container */}
|
||||
<div className="h-[400px] flex flex-col">
|
||||
{/* Step Content - Flexible height container */}
|
||||
<div className="min-h-[400px] flex flex-col">
|
||||
{currentStep === 1 && (
|
||||
<WalletConnectionStep
|
||||
onComplete={() => handleStepComplete(1)}
|
||||
@ -185,7 +176,7 @@ export function WalletWizard({
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{currentStep > 1 && (
|
||||
<Button
|
||||
@ -193,7 +184,7 @@ export function WalletWizard({
|
||||
size="sm"
|
||||
onClick={() => setCurrentStep((currentStep - 1) as WizardStep)}
|
||||
disabled={isLoading}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
className="text-neutral-400 hover:text-white hover:bg-neutral-800"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@ -14,6 +14,8 @@ import {
|
||||
} from '@/lib/delegation';
|
||||
import { localDatabase } from '@/lib/database/LocalDatabase';
|
||||
import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react';
|
||||
import { MessageService } from '@/lib/services/MessageService';
|
||||
import { UserIdentityService } from '@/lib/services/UserIdentityService';
|
||||
|
||||
interface AuthContextType {
|
||||
currentUser: User | null;
|
||||
@ -56,6 +58,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Create manager instances
|
||||
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
|
||||
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> => {
|
||||
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 {
|
||||
const walletInfo = WalletManager.hasInstance()
|
||||
? await WalletManager.getInstance().getWalletInfo()
|
||||
: null;
|
||||
const hasENS = !!walletInfo?.ensName;
|
||||
const ensName = walletInfo?.ensName;
|
||||
|
||||
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);
|
||||
try {
|
||||
// Force fresh resolution to ensure API call happens during verification
|
||||
const identity = await userIdentityService.getUserIdentityFresh(
|
||||
user.address
|
||||
);
|
||||
if (!identity) {
|
||||
return {
|
||||
...user,
|
||||
ensDetails: undefined,
|
||||
ordinalDetails: undefined,
|
||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||
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
|
||||
);
|
||||
await saveUser(updatedUser);
|
||||
await localDatabase.upsertUserIdentity(updatedUser.address, {
|
||||
ensName: walletInfo.ensName,
|
||||
verificationStatus:
|
||||
EVerificationStatus.ENS_ORDINAL_VERIFIED,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
await localDatabase.upsertUserIdentity(
|
||||
updatedUser.address,
|
||||
{
|
||||
ensName: walletInfo.ensName,
|
||||
verificationStatus:
|
||||
EVerificationStatus.ENS_ORDINAL_VERIFIED,
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setCurrentUser(newUser);
|
||||
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
|
||||
|
||||
@ -121,7 +121,7 @@ export class LocalDatabase {
|
||||
public async clearAll(): Promise<void> {
|
||||
// Clear in-memory cache
|
||||
this.clear();
|
||||
|
||||
|
||||
// Clear all IndexedDB stores
|
||||
if (!this.db) return;
|
||||
|
||||
@ -140,7 +140,7 @@ export class LocalDatabase {
|
||||
];
|
||||
|
||||
const tx = this.db.transaction(storeNames, 'readwrite');
|
||||
|
||||
|
||||
await Promise.all(
|
||||
storeNames.map(storeName => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@ -632,7 +632,8 @@ export class LocalDatabase {
|
||||
record: Partial<UserIdentityCache[string]> & { lastUpdated?: number }
|
||||
): Promise<void> {
|
||||
const existing: UserIdentityCache[string] =
|
||||
this.cache.userIdentities[address] || {
|
||||
this.cache.userIdentities[address] ||
|
||||
({
|
||||
ensName: undefined,
|
||||
ordinalDetails: undefined,
|
||||
callSign: undefined,
|
||||
@ -644,12 +645,15 @@ export class LocalDatabase {
|
||||
// Casting below ensures the object satisfies the interface at compile time.
|
||||
lastUpdated: 0,
|
||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
||||
} as unknown as UserIdentityCache[string];
|
||||
} as unknown as UserIdentityCache[string]);
|
||||
|
||||
const merged: UserIdentityCache[string] = {
|
||||
...existing,
|
||||
...record,
|
||||
lastUpdated: Math.max(existing.lastUpdated ?? 0, record.lastUpdated ?? Date.now()),
|
||||
lastUpdated: Math.max(
|
||||
existing.lastUpdated ?? 0,
|
||||
record.lastUpdated ?? Date.now()
|
||||
),
|
||||
} as UserIdentityCache[string];
|
||||
|
||||
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 messageManager from '@/lib/waku';
|
||||
import { localDatabase } from '@/lib/database/LocalDatabase';
|
||||
import { WalletManager } from '@/lib/wallet';
|
||||
|
||||
export interface UserIdentity {
|
||||
address: string;
|
||||
@ -155,6 +156,29 @@ export class UserIdentityService {
|
||||
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
|
||||
*/
|
||||
@ -329,8 +353,19 @@ export class UserIdentityService {
|
||||
address: string
|
||||
): Promise<{ ordinalId: string; ordinalDetails: string } | null> {
|
||||
try {
|
||||
//TODO: add Ordinal API call
|
||||
console.log('resolveOrdinalDetails', address);
|
||||
if (address.startsWith('0x')) {
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve Ordinal details:', error);
|
||||
@ -375,12 +410,22 @@ export class UserIdentityService {
|
||||
*/
|
||||
private mapVerificationStatus(status: string): EVerificationStatus {
|
||||
switch (status) {
|
||||
// Legacy message-cache statuses
|
||||
case 'verified-basic':
|
||||
return EVerificationStatus.WALLET_CONNECTED;
|
||||
case 'verified-owner':
|
||||
return EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
case 'verifying':
|
||||
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:
|
||||
return EVerificationStatus.WALLET_UNCONNECTED;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { UseAppKitAccountReturn } from '@reown/appkit/react';
|
||||
import { AppKit } from '@reown/appkit';
|
||||
import { ordinals } from '@/lib/services/Ordinals';
|
||||
import {
|
||||
getEnsName,
|
||||
verifyMessage as verifyEthereumMessage,
|
||||
@ -8,6 +9,7 @@ import { ChainNamespace } from '@reown/appkit-common';
|
||||
import { config } from './config';
|
||||
import { Provider } from '@reown/appkit-controllers';
|
||||
import { WalletInfo, ActiveWallet } from './types';
|
||||
import { Inscription } from 'ordiscan';
|
||||
export class WalletManager {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -15,7 +15,9 @@ export default function DebugPage() {
|
||||
useEffect(() => {
|
||||
// Subscribe to inbound messages from reliable channel
|
||||
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 () => {
|
||||
@ -47,7 +49,9 @@ export default function DebugPage() {
|
||||
Total received: {messages.length}
|
||||
</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 => (
|
||||
<div
|
||||
key={t}
|
||||
@ -59,13 +63,22 @@ export default function DebugPage() {
|
||||
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 style={{ marginTop: 16, borderTop: '1px solid #334155', paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Recent messages</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderTop: '1px solid #334155',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>
|
||||
Recent messages
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -79,9 +92,11 @@ export default function DebugPage() {
|
||||
<div style={{ fontWeight: 700, color: '#cbd5e1' }}>ID / Author</div>
|
||||
<div style={{ fontWeight: 700, color: '#cbd5e1' }}>Msg Timestamp</div>
|
||||
{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={{ textTransform: 'capitalize', color: '#e5e7eb' }}>{m.message.type}</div>
|
||||
<div style={{ textTransform: 'capitalize', color: '#e5e7eb' }}>
|
||||
{m.message.type}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
@ -91,9 +106,12 @@ export default function DebugPage() {
|
||||
}}
|
||||
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 style={{ color: '#e5e7eb' }}>{formatTs(m.message.timestamp)}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -277,7 +277,9 @@ export default function ProfilePage() {
|
||||
{userInfo.displayName}
|
||||
</div>
|
||||
<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 className="flex items-center gap-2 mt-2">
|
||||
{getVerificationIcon()}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user