descro playgound & sdk

This commit is contained in:
thesn10
2026-05-18 22:02:41 +02:00
parent 84a2ec4200
commit 2a89352212
37 changed files with 8775 additions and 12088 deletions

4
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.next/
node_modules/
public/tamagui.generated.css
.env*.local

1
app/.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

6
app/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

27
app/next.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
transpilePackages: [
'@tamagui/lucide-icons-2',
'@descro/sdk',
'@solana/wallet-adapter-react-ui',
],
turbopack: {
resolveAlias: {
'react-native': 'react-native-web',
'react-native-svg': '@tamagui/react-native-svg',
},
},
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
os: false,
path: false,
crypto: false,
}
return config
},
}
export default nextConfig

32
app/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "descro-app",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "tamagui build --target web ./src -- next build",
"start": "next start"
},
"dependencies": {
"@coral-xyz/anchor": "^0.32.1",
"@descro/sdk": "workspace:*",
"@solana/wallet-adapter-base": "^0.9.27",
"@solana/wallet-adapter-phantom": "^0.9.29",
"@solana/wallet-adapter-react": "^0.15.39",
"@solana/wallet-adapter-react-ui": "^0.9.39",
"@solana/web3.js": "^1.98.4",
"@tamagui/config": "^2.0.0-rc.42",
"@tamagui/next-theme": "^2.0.0-rc.42",
"next": "^16.2.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-native-web": "^0.19.13",
"tamagui": "^2.0.0-rc.42"
},
"devDependencies": {
"@tamagui/cli": "^2.0.0-rc.42",
"@types/node": "^25.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,23 @@
'use client'
import { ReactNode } from 'react'
import { NextThemeProvider, useRootTheme } from '@tamagui/next-theme'
import { TamaguiProvider } from 'tamagui'
import tamaguiConfig from '../../tamagui.config'
export const NextTamaguiProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useRootTheme()
return (
<NextThemeProvider
skipNextHead
onChangeTheme={(next) => {
setTheme(next as any)
}}
>
<TamaguiProvider config={tamaguiConfig} disableRootThemeClass defaultTheme={theme}>
{children}
</TamaguiProvider>
</NextThemeProvider>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import React, { useMemo, useState } from 'react'
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react'
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui'
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom'
export type Network = 'devnet' | 'localnet'
interface NetworkContextValue {
network: Network
setNetwork: (n: Network) => void
endpoint: string
}
export const NetworkContext = React.createContext<NetworkContextValue>({
network: 'devnet',
setNetwork: () => undefined,
endpoint: 'https://api.devnet.solana.com',
})
export function getEndpoint(network: Network): string {
if (network === 'localnet') return 'http://localhost:8899'
return 'https://api.devnet.solana.com'
}
export function WalletProviders({ children }: { children: React.ReactNode }) {
const [network, setNetwork] = useState<Network>('devnet')
const endpoint = getEndpoint(network)
const wallets = useMemo(
() => [new PhantomWalletAdapter()],
// eslint-disable-next-line react-hooks/exhaustive-deps
[network]
)
return (
<NetworkContext.Provider value={{ network, setNetwork, endpoint }}>
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
</NetworkContext.Provider>
)
}

23
app/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import '../../public/tamagui.generated.css'
import '@solana/wallet-adapter-react-ui/styles.css'
import { Metadata } from 'next'
import { NextTamaguiProvider } from './NextTamaguiProvider'
import { WalletProviders } from './WalletProviders'
export const metadata: Metadata = {
title: 'Descro Playground',
description: 'Descro Escrow Protocol Playground',
icons: '/favicon.ico',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body style={{ margin: 0, padding: 0, fontFamily: 'system-ui, sans-serif' }}>
<NextTamaguiProvider>
<WalletProviders>{children}</WalletProviders>
</NextTamaguiProvider>
</body>
</html>
)
}

184
app/src/app/page.tsx Normal file
View File

@@ -0,0 +1,184 @@
'use client'
import React, { useContext, useState } from 'react'
import { YStack, XStack, Text, Button, Spinner, Separator } from 'tamagui'
import { WalletButton } from '@/components/WalletButton'
import { CreateEscrowForm } from '@/components/CreateEscrowForm'
import { EscrowCard } from '@/components/EscrowCard'
import { EscrowDetail } from '@/components/EscrowDetail'
import { ResolverPanel } from '@/components/ResolverPanel'
import { useEscrows } from '@/hooks/useEscrows'
import { NetworkContext } from './WalletProviders'
import type { Network } from './WalletProviders'
import type { EscrowAccountWithPda } from '@descro/sdk'
import { useWallet } from '@solana/wallet-adapter-react'
export default function PlaygroundPage() {
const { network, setNetwork } = useContext(NetworkContext)
const { publicKey } = useWallet()
const { escrows, loading, error, refresh } = useEscrows()
const [selected, setSelected] = useState<EscrowAccountWithPda | null>(null)
const [showResolvers, setShowResolvers] = useState(false)
return (
<YStack flex={1} minHeight="100vh" backgroundColor="$background">
{/* Header */}
<XStack
padding="$4"
borderBottomWidth={1}
borderColor="$borderColor"
justifyContent="space-between"
alignItems="center"
flexWrap="wrap"
gap="$3"
backgroundColor="$backgroundStrong"
>
<Text fontSize={22} fontWeight="800" color="$blue9">
Descro Playground
</Text>
<XStack gap="$3" alignItems="center" flexWrap="wrap">
<XStack gap="$2" alignItems="center">
<Text fontSize={13} color="$color10">
Network:
</Text>
<select
value={network}
onChange={(e) => setNetwork(e.target.value as Network)}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid #ccc',
fontSize: 13,
cursor: 'pointer',
}}
>
<option value="devnet">Devnet</option>
<option value="localnet">Localnet</option>
</select>
</XStack>
{network === 'devnet' && (
<a
href="https://faucet.solana.com/"
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, color: '#512da8', textDecoration: 'none' }}
>
Devnet Faucet
</a>
)}
<WalletButton />
</XStack>
</XStack>
{/* Main content */}
<XStack flex={1} padding="$4" gap="$4" flexWrap="wrap" alignItems="flex-start">
{/* Left panel */}
<YStack
flex={1}
minWidth={300}
maxWidth={420}
gap="$4"
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
padding="$4"
>
<CreateEscrowForm onCreated={refresh} />
<Separator />
<YStack gap="$3">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize={15} fontWeight="600">
Your Escrows
</Text>
<Button size="$2" onPress={refresh} disabled={loading}>
{loading ? <Spinner size="small" /> : 'Refresh'}
</Button>
</XStack>
{!publicKey && (
<Text color="$color10" fontSize={13}>
Connect your wallet to see escrows.
</Text>
)}
{error && (
<XStack backgroundColor="$red3" padding="$2" borderRadius="$2">
<Text color="$red9" fontSize={13}>
{error}
</Text>
</XStack>
)}
{publicKey && !loading && escrows.length === 0 && (
<Text color="$color10" fontSize={13}>
No escrows found for your wallet.
</Text>
)}
<YStack gap="$2">
{escrows.map((item) => (
<EscrowCard
key={item.pda.toBase58()}
item={item}
selected={selected?.pda.equals(item.pda) ?? false}
onSelect={() => setSelected(item)}
/>
))}
</YStack>
</YStack>
</YStack>
{/* Right panel */}
<YStack flex={2} minWidth={300} gap="$4">
{selected ? (
<EscrowDetail item={selected} onAction={refresh} />
) : (
<YStack
padding="$4"
borderRadius="$4"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
justifyContent="center"
minHeight={200}
>
<Text color="$color10" fontSize={14}>
Select an escrow to view details and take actions.
</Text>
</YStack>
)}
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$4" overflow="hidden">
<XStack
padding="$3"
backgroundColor="$backgroundStrong"
justifyContent="space-between"
alignItems="center"
onPress={() => setShowResolvers((v) => !v)}
cursor="pointer"
pressStyle={{ opacity: 0.7 }}
>
<Text fontSize={15} fontWeight="600">
Resolver Registry
</Text>
<Text fontSize={18} color="$color10">
{showResolvers ? '▲' : '▼'}
</Text>
</XStack>
{showResolvers && (
<YStack padding="$4">
<ResolverPanel />
</YStack>
)}
</YStack>
</YStack>
</XStack>
</YStack>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import React, { useState } from 'react'
import { YStack, XStack, Text, Button, Input, Spinner } from 'tamagui'
import { useWallet, useConnection } from '@solana/wallet-adapter-react'
import { PublicKey, Transaction } from '@solana/web3.js'
import { AnchorProvider } from '@coral-xyz/anchor'
import BN from 'bn.js'
import { EscrowClient } from '@descro/sdk'
function getNextEscrowId(walletPubkey: string): BN {
const key = `descro_escrow_id_${walletPubkey}`
const stored = localStorage.getItem(key)
const current = stored ? parseInt(stored, 10) : 0
const next = current + 1
localStorage.setItem(key, next.toString())
return new BN(next)
}
interface CreateEscrowFormProps {
onCreated?: () => void
}
export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) {
const { publicKey, sendTransaction, signTransaction } = useWallet()
const { connection } = useConnection()
const [buyer, setBuyer] = useState('')
const [amountSol, setAmountSol] = useState('')
const [resolver, setResolver] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSuccess(null)
if (!publicKey) {
setError('Connect your wallet first.')
return
}
let buyerPk: PublicKey
try {
buyerPk = new PublicKey(buyer)
} catch {
setError('Invalid buyer public key.')
return
}
const amountLamports = parseFloat(amountSol)
if (isNaN(amountLamports) || amountLamports <= 0) {
setError('Invalid SOL amount.')
return
}
let resolverPk: PublicKey | null = null
if (resolver.trim()) {
try {
resolverPk = new PublicKey(resolver.trim())
} catch {
setError('Invalid resolver public key.')
return
}
}
setLoading(true)
try {
const provider = new AnchorProvider(
connection,
{ publicKey, signTransaction: signTransaction! } as any,
{}
)
const client = new EscrowClient(provider)
const escrowId = getNextEscrowId(publicKey.toBase58())
const amount = new BN(Math.round(amountLamports * 1e9))
const ix = await client.buildCreateEscrow({
seller: publicKey,
buyer: buyerPk,
amount,
disputeResolver: resolverPk,
escrowId,
})
const tx = new Transaction().add(ix)
const sig = await sendTransaction(tx, connection)
await connection.confirmTransaction(sig, 'confirmed')
setSuccess(`Escrow #${escrowId.toString()} created! Tx: ${sig.slice(0, 16)}`)
setBuyer('')
setAmountSol('')
setResolver('')
onCreated?.()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
return (
<YStack gap="$3">
<Text fontSize={16} fontWeight="700">Create Escrow</Text>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Buyer Public Key *</Text>
<Input
value={buyer}
onChangeText={setBuyer}
placeholder="Pubkey..."
fontFamily="monospace"
fontSize={13}
/>
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Amount (SOL) *</Text>
<Input
value={amountSol}
onChangeText={setAmountSol}
placeholder="0.1"
keyboardType="decimal-pad"
fontSize={13}
/>
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Dispute Resolver (optional)</Text>
<Input
value={resolver}
onChangeText={setResolver}
placeholder="Pubkey..."
fontFamily="monospace"
fontSize={13}
/>
</YStack>
{error && (
<XStack backgroundColor="$red3" padding="$2" borderRadius="$2">
<Text color="$red9" fontSize={13}>{error}</Text>
</XStack>
)}
{success && (
<XStack backgroundColor="$green3" padding="$2" borderRadius="$2">
<Text color="$green9" fontSize={13}>{success}</Text>
</XStack>
)}
<Button
theme="active"
onPress={handleSubmit as unknown as () => void}
disabled={loading || !publicKey}
icon={loading ? <Spinner /> : undefined}
>
{loading ? 'Creating…' : 'Create Escrow'}
</Button>
</form>
</YStack>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import React from 'react'
import { YStack, XStack, Text } from 'tamagui'
import { StatusBadge } from './StatusBadge'
import type { EscrowAccountWithPda } from '@descro/sdk'
import { PublicKey } from '@solana/web3.js'
function truncPubkey(pk: PublicKey): string {
const s = pk.toBase58()
return `${s.slice(0, 4)}${s.slice(-4)}`
}
interface EscrowCardProps {
item: EscrowAccountWithPda
selected: boolean
onSelect: () => void
}
export function EscrowCard({ item, selected, onSelect }: EscrowCardProps) {
const { pda, account } = item
const solAmount = (Number(account.amount.toString()) / 1e9).toFixed(4)
return (
<YStack
padding="$4"
borderRadius="$4"
borderWidth={1}
borderColor={selected ? '$blue9' : '$borderColor'}
backgroundColor={selected ? '$blue2' : '$background'}
pressStyle={{ opacity: 0.8 }}
onPress={onSelect}
cursor="pointer"
gap="$2"
>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize={12} color="$color11">
#{account.escrowId.toString()}
</Text>
<StatusBadge state={account.state} />
</XStack>
<Text fontSize={16} fontWeight="700">
{solAmount} SOL
</Text>
<XStack gap="$4">
<YStack>
<Text fontSize={11} color="$color10">Seller</Text>
<Text fontSize={12} fontFamily="monospace">{truncPubkey(account.seller)}</Text>
</YStack>
<YStack>
<Text fontSize={11} color="$color10">Buyer</Text>
<Text fontSize={12} fontFamily="monospace">{truncPubkey(account.buyer)}</Text>
</YStack>
</XStack>
<Text fontSize={11} color="$color10" fontFamily="monospace">
PDA: {truncPubkey(pda)}
</Text>
</YStack>
)
}

View File

@@ -0,0 +1,206 @@
'use client'
import React, { useState } from 'react'
import { YStack, XStack, Text, Button, Spinner, Separator } from 'tamagui'
import { useWallet, useConnection } from '@solana/wallet-adapter-react'
import { PublicKey, Transaction } from '@solana/web3.js'
import { AnchorProvider } from '@coral-xyz/anchor'
import { StatusBadge } from './StatusBadge'
import {
EscrowClient,
isAwaitingDeposit,
isActive,
isDisputed,
} from '@descro/sdk'
import type { EscrowAccountWithPda } from '@descro/sdk'
import { useEscrowDetail } from '@/hooks/useEscrowDetail'
interface EscrowDetailProps {
item: EscrowAccountWithPda
onAction?: () => void
}
function fullPubkey(pk: PublicKey): string {
return pk.toBase58()
}
export function EscrowDetail({ item, onAction }: EscrowDetailProps) {
const { pda } = item
const { account, loading: detailLoading } = useEscrowDetail(pda)
const escrow = account ?? item.account
const { publicKey, sendTransaction, signTransaction } = useWallet()
const { connection } = useConnection()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const solAmount = (Number(escrow.amount.toString()) / 1e9).toFixed(4)
const isSeller = publicKey?.equals(escrow.seller) ?? false
const isBuyer = publicKey?.equals(escrow.buyer) ?? false
const isResolver =
escrow.disputeResolver != null &&
publicKey?.equals(escrow.disputeResolver) === true
async function runTx(
buildFn: (client: EscrowClient) => Promise<import('@solana/web3.js').TransactionInstruction>
) {
if (!publicKey || !signTransaction) {
setError('Connect your wallet first.')
return
}
setLoading(true)
setError(null)
setSuccess(null)
try {
const provider = new AnchorProvider(
connection,
{ publicKey, signTransaction } as any,
{}
)
const client = new EscrowClient(provider)
const ix = await buildFn(client)
const tx = new Transaction().add(ix)
const sig = await sendTransaction(tx, connection)
await connection.confirmTransaction(sig, 'confirmed')
setSuccess(`Done! Tx: ${sig.slice(0, 20)}`)
onAction?.()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
function handleDeposit() {
runTx((client) => client.buildDeposit({ buyer: publicKey!, escrowPda: pda }))
}
function handleCancel() {
runTx((client) => client.buildCancel({ seller: publicKey!, escrowPda: pda }))
}
function handleComplete() {
runTx((client) =>
client.buildComplete({ buyer: publicKey!, seller: escrow.seller, escrowPda: pda })
)
}
function handleDispute() {
runTx((client) => client.buildDispute({ initiator: publicKey!, escrowPda: pda }))
}
function handleResolve(winner: 'buyer' | 'seller') {
const winnerPubkey = winner === 'buyer' ? escrow.buyer : escrow.seller
runTx((client) =>
client.buildResolve({
resolver: publicKey!,
winner,
winnerPubkey,
seller: escrow.seller,
escrowPda: pda,
disputeResolver: escrow.disputeResolver,
})
)
}
return (
<YStack
padding="$4"
borderRadius="$4"
borderWidth={1}
borderColor="$borderColor"
gap="$3"
>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize={18} fontWeight="700">
Escrow #{escrow.escrowId.toString()}
</Text>
<StatusBadge state={escrow.state} />
</XStack>
<Separator />
<YStack gap="$2">
<Row label="Amount" value={`${solAmount} SOL`} />
<Row label="Seller" value={fullPubkey(escrow.seller)} mono />
<Row label="Buyer" value={fullPubkey(escrow.buyer)} mono />
<Row
label="Resolver"
value={escrow.disputeResolver ? fullPubkey(escrow.disputeResolver) : 'None'}
mono={!!escrow.disputeResolver}
/>
<Row label="PDA" value={fullPubkey(pda)} mono />
</YStack>
<Separator />
<YStack gap="$2">
{isAwaitingDeposit(escrow.state) && isBuyer && (
<Button theme="active" onPress={handleDeposit} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Deposit
</Button>
)}
{isAwaitingDeposit(escrow.state) && isSeller && (
<Button theme="red" onPress={handleCancel} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Cancel
</Button>
)}
{isActive(escrow.state) && isBuyer && (
<>
<Button theme="green" onPress={handleComplete} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Complete
</Button>
<Button theme="orange" onPress={handleDispute} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Dispute
</Button>
</>
)}
{isActive(escrow.state) && isSeller && !isBuyer && (
<Button theme="orange" onPress={handleDispute} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Dispute
</Button>
)}
{isDisputed(escrow.state) && isResolver && (
<>
<Button theme="green" onPress={() => handleResolve('seller')} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Resolve: Seller Wins
</Button>
<Button theme="active" onPress={() => handleResolve('buyer')} disabled={loading} icon={loading ? <Spinner /> : undefined}>
Resolve: Buyer Wins
</Button>
</>
)}
</YStack>
{error && (
<XStack backgroundColor="$red3" padding="$2" borderRadius="$2">
<Text color="$red9" fontSize={13}>{error}</Text>
</XStack>
)}
{success && (
<XStack backgroundColor="$green3" padding="$2" borderRadius="$2">
<Text color="$green9" fontSize={13}>{success}</Text>
</XStack>
)}
</YStack>
)
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<XStack gap="$2" flexWrap="wrap">
<Text fontSize={13} color="$color10" width={80} flexShrink={0}>
{label}
</Text>
<Text
fontSize={13}
fontFamily={mono ? 'monospace' : undefined}
flex={1}
style={{ wordBreak: 'break-all' }}
>
{value}
</Text>
</XStack>
)
}

View File

@@ -0,0 +1,241 @@
'use client'
import React, { useState, useEffect } from 'react'
import { YStack, XStack, Text, Button, Input, Spinner, Separator } from 'tamagui'
import { useWallet, useConnection } from '@solana/wallet-adapter-react'
import { PublicKey, Transaction } from '@solana/web3.js'
import { AnchorProvider } from '@coral-xyz/anchor'
import { RegistryClient, resolverTypeLabel } from '@descro/sdk'
import type { ResolverEntryWithPda, ResolverType } from '@descro/sdk'
const RESOLVER_TYPE_OPTIONS: { label: string; value: ResolverType }[] = [
{ label: 'CentralAuthority', value: { centralAuthority: {} } },
{ label: 'JuryDAO', value: { juryDAO: {} } },
{ label: 'MAD', value: { mad: {} } },
{ label: 'Algorithmic', value: { algorithmic: {} } },
{ label: 'Multisig', value: { multisig: {} } },
]
function truncPk(pk: PublicKey): string {
const s = pk.toBase58()
return `${s.slice(0, 4)}${s.slice(-4)}`
}
export function ResolverPanel() {
const { publicKey, sendTransaction, signTransaction } = useWallet()
const { connection } = useConnection()
const [resolvers, setResolvers] = useState<ResolverEntryWithPda[]>([])
const [resolversLoading, setResolversLoading] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [feeBps, setFeeBps] = useState('0')
const [feeRecipient, setFeeRecipient] = useState('')
const [metadataUri, setMetadataUri] = useState('')
const [resolverTypeIdx, setResolverTypeIdx] = useState(0)
const [formLoading, setFormLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
async function loadResolvers() {
setResolversLoading(true)
try {
const provider = new AnchorProvider(connection, {} as any, {})
const client = new RegistryClient(provider)
const all = await client.fetchAllResolvers()
setResolvers(all)
} catch (err) {
console.error('Failed to load resolvers', err)
} finally {
setResolversLoading(false)
}
}
useEffect(() => {
loadResolvers()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection])
async function handleRegister(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSuccess(null)
if (!publicKey || !signTransaction) {
setError('Connect your wallet first.')
return
}
let recipientPk: PublicKey
const recipientStr = feeRecipient.trim() || publicKey.toBase58()
try {
recipientPk = new PublicKey(recipientStr)
} catch {
setError('Invalid fee recipient public key.')
return
}
const fee = parseInt(feeBps, 10)
if (isNaN(fee) || fee < 0 || fee > 10000) {
setError('Fee BPS must be between 0 and 10000.')
return
}
if (!name.trim()) {
setError('Name is required.')
return
}
setFormLoading(true)
try {
const provider = new AnchorProvider(
connection,
{ publicKey, signTransaction } as any,
{}
)
const client = new RegistryClient(provider)
const resolverType = RESOLVER_TYPE_OPTIONS[resolverTypeIdx].value
const ix = await client.buildRegisterResolver({
authority: publicKey,
resolverType,
name: name.trim(),
description: description.trim(),
feeBps: fee,
feeRecipient: recipientPk,
metadataUri: metadataUri.trim(),
})
const tx = new Transaction().add(ix)
const sig = await sendTransaction(tx, connection)
await connection.confirmTransaction(sig, 'confirmed')
setSuccess(`Registered! Tx: ${sig.slice(0, 20)}`)
setName('')
setDescription('')
setFeeBps('0')
setFeeRecipient('')
setMetadataUri('')
loadResolvers()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setFormLoading(false)
}
}
return (
<YStack gap="$4">
<Text fontSize={18} fontWeight="700">Resolver Registry</Text>
<YStack gap="$2">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize={15} fontWeight="600">Registered Resolvers</Text>
<Button size="$2" onPress={loadResolvers} disabled={resolversLoading}>
{resolversLoading ? <Spinner size="small" /> : 'Refresh'}
</Button>
</XStack>
{resolvers.length === 0 && !resolversLoading && (
<Text color="$color10" fontSize={13}>No resolvers registered yet.</Text>
)}
{resolvers.map(({ pda, account }) => (
<YStack
key={pda.toBase58()}
padding="$3"
borderRadius="$3"
borderWidth={1}
borderColor="$borderColor"
gap="$1"
>
<XStack justifyContent="space-between">
<Text fontWeight="600">{account.name}</Text>
<Text fontSize={12} color="$color10">{resolverTypeLabel(account.resolverType)}</Text>
</XStack>
{account.description && (
<Text fontSize={12} color="$color10">{account.description}</Text>
)}
<XStack gap="$4">
<Text fontSize={12}>Fee: {account.feeBps} bps</Text>
<Text fontSize={12}>Resolved: {account.totalResolved.toString()}</Text>
<Text fontSize={12} color="$green9">B: {account.ruledForBuyer.toString()}</Text>
<Text fontSize={12} color="$orange9">S: {account.ruledForSeller.toString()}</Text>
</XStack>
<Text fontSize={11} fontFamily="monospace" color="$color10">
{truncPk(account.authority)}
</Text>
</YStack>
))}
</YStack>
<Separator />
<YStack gap="$3">
<Text fontSize={15} fontWeight="600">Register as Resolver</Text>
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Name *</Text>
<Input value={name} onChangeText={setName} placeholder="My Resolver" fontSize={13} />
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Description</Text>
<Input value={description} onChangeText={setDescription} placeholder="What I do..." fontSize={13} />
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Resolver Type</Text>
<select
value={resolverTypeIdx}
onChange={(e) => setResolverTypeIdx(parseInt(e.target.value, 10))}
style={{ padding: '8px', borderRadius: 6, border: '1px solid #ccc', fontSize: 13 }}
>
{RESOLVER_TYPE_OPTIONS.map((opt, i) => (
<option key={i} value={i}>{opt.label}</option>
))}
</select>
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Fee (BPS)</Text>
<Input value={feeBps} onChangeText={setFeeBps} placeholder="0" keyboardType="numeric" fontSize={13} />
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Fee Recipient (default: your wallet)</Text>
<Input value={feeRecipient} onChangeText={setFeeRecipient} placeholder="Pubkey or leave blank" fontFamily="monospace" fontSize={13} />
</YStack>
<YStack gap="$1">
<Text fontSize={13} color="$color11">Metadata URI</Text>
<Input value={metadataUri} onChangeText={setMetadataUri} placeholder="https://..." fontSize={13} />
</YStack>
{error && (
<XStack backgroundColor="$red3" padding="$2" borderRadius="$2">
<Text color="$red9" fontSize={13}>{error}</Text>
</XStack>
)}
{success && (
<XStack backgroundColor="$green3" padding="$2" borderRadius="$2">
<Text color="$green9" fontSize={13}>{success}</Text>
</XStack>
)}
<Button
theme="active"
onPress={handleRegister as unknown as () => void}
disabled={formLoading || !publicKey}
icon={formLoading ? <Spinner /> : undefined}
>
{formLoading ? 'Registering…' : 'Register'}
</Button>
</form>
</YStack>
</YStack>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import React from 'react'
import { XStack, Text } from 'tamagui'
import { escrowStateLabel } from '@descro/sdk'
import type { EscrowState } from '@descro/sdk'
const STATE_COLORS: Record<string, string> = {
AwaitingDeposit: '$blue9',
Active: '$green9',
Disputed: '$orange9',
Complete: '$gray9',
Cancelled: '$red9',
}
interface StatusBadgeProps {
state: EscrowState
}
export function StatusBadge({ state }: StatusBadgeProps) {
const label = escrowStateLabel(state)
const color = STATE_COLORS[label] ?? '$gray9'
return (
<XStack
backgroundColor={color}
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius="$2"
alignSelf="flex-start"
>
<Text color="white" fontSize={12} fontWeight="600">
{label}
</Text>
</XStack>
)
}

View File

@@ -0,0 +1,18 @@
'use client'
import React from 'react'
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui'
export function WalletButton() {
return (
<WalletMultiButton
style={{
backgroundColor: '#512da8',
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
padding: '8px 16px',
}}
/>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { PublicKey } from '@solana/web3.js'
import { useConnection } from '@solana/wallet-adapter-react'
import { AnchorProvider } from '@coral-xyz/anchor'
import { EscrowClient, subscribeEscrow } from '@descro/sdk'
import type { EscrowAccount } from '@descro/sdk'
export function useEscrowDetail(pda: PublicKey | null) {
const { connection } = useConnection()
const [account, setAccount] = useState<EscrowAccount | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const unsubRef = useRef<(() => void) | null>(null)
useEffect(() => {
if (!pda) {
setAccount(null)
return
}
let cancelled = false
setLoading(true)
const provider = new AnchorProvider(connection, {} as any, {})
const client = new EscrowClient(provider)
client
.fetchEscrow(pda)
.then((acc) => {
if (!cancelled) {
setAccount(acc)
setError(null)
}
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
const unsub = subscribeEscrow(connection, pda, (updated) => {
if (!cancelled) setAccount(updated)
})
unsubRef.current = unsub
return () => {
cancelled = true
unsub()
unsubRef.current = null
}
}, [pda?.toBase58(), connection])
return { account, loading, error }
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useWallet, useConnection } from '@solana/wallet-adapter-react'
import { AnchorProvider } from '@coral-xyz/anchor'
import { EscrowClient } from '@descro/sdk'
import type { EscrowAccountWithPda } from '@descro/sdk'
const POLL_INTERVAL_MS = 5000
export function useEscrows() {
const { publicKey } = useWallet()
const { connection } = useConnection()
const [escrows, setEscrows] = useState<EscrowAccountWithPda[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchEscrows = useCallback(async () => {
if (!publicKey) {
setEscrows([])
return
}
try {
const provider = new AnchorProvider(connection, {} as any, {})
const client = new EscrowClient(provider)
const results = await client.fetchEscrowsForWallet(publicKey)
results.sort((a, b) =>
Number(b.account.escrowId.toString()) - Number(a.account.escrowId.toString())
)
setEscrows(results)
setError(null)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
}
}, [publicKey, connection])
useEffect(() => {
setLoading(true)
fetchEscrows().finally(() => setLoading(false))
intervalRef.current = setInterval(fetchEscrows, POLL_INTERVAL_MS)
return () => {
if (intervalRef.current !== null) clearInterval(intervalRef.current)
}
}, [fetchEscrows])
return { escrows, loading, error, refresh: fetchEscrows }
}

7
app/tamagui.build.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { TamaguiBuildOptions } from '@tamagui/core'
export default {
components: ['@tamagui/core'],
config: './tamagui.config.ts',
outputCSS: './public/tamagui.generated.css',
} satisfies TamaguiBuildOptions

12
app/tamagui.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defaultConfig } from '@tamagui/config/v5'
import { createTamagui } from 'tamagui'
const appConfig = createTamagui(defaultConfig)
export type AppConfig = typeof appConfig
declare module 'tamagui' {
interface TamaguiCustomConfig extends AppConfig {}
}
export default appConfig

41
app/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}