Compare commits

...

10 Commits

Author SHA1 Message Date
thesn10
aafbc17e94 fix order state 2026-06-27 15:13:36 +02:00
thesn10
aac025e2a8 fix empty resolver 2026-06-27 15:00:06 +02:00
thesn10
e799c0be9d resolver picker 2026-06-27 14:52:11 +02:00
thesn10
0c86751d51 fix escrow handling and fetching 2026-06-26 20:01:36 +02:00
thesn10
02c26e186f dont close on accept order 2026-06-26 19:13:30 +02:00
thesn10
12f1344138 better error handling 2026-06-26 19:13:17 +02:00
thesn10
94d2d67e83 load descro 2026-06-26 19:12:50 +02:00
thesn10
ec1c7d9f8c small fixes 2026-06-26 18:36:34 +02:00
thesn10
555598535d feat: add name and description fields to ListingAccount; update form, table, detail, dashboard 2026-06-25 20:21:28 +02:00
thesn10
ca1c0b1cd8 fix: filter highlight, nav alignment, button centering, listing title, dashboard listing name 2026-06-25 19:59:03 +02:00
35 changed files with 1233 additions and 671 deletions

View File

@@ -18,3 +18,11 @@ wallet = "~/.config/solana/id.json"
test = "cargo test"
[hooks]
[[test.genesis]]
address = "DjVR4EuYV6USMJFfsGZwhZ3y8rtWsmG8EvDY96GTqqi3"
program = "../descro/target/deploy/descro.so"
[[test.genesis]]
address = "GwUPAKs3HHzCpj8uhet4NAnxk9GWNwfrYbpihu5DyFp"
program = "../descro/target/deploy/descro_ext_resolvers.so"

View File

@@ -0,0 +1,8 @@
import { use } from 'react'
import { EscrowDetail } from '@/components/EscrowDetail'
import type { Address } from '@solana/kit'
export default function EscrowPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
return <EscrowDetail pda={pk as Address} />
}

View File

@@ -6,6 +6,7 @@ import { useWallet, useKitTransactionSigner } from '@solana/connector/react'
import { ListingDetail } from '@/components/ListingDetail'
import { useTx } from '@/hooks/useTx'
import { useListing } from '@/hooks/useListing'
import { useResolvers } from '@/hooks/useResolvers'
import {
getCloseListingInstruction,
getCreateOrderInstructionAsync,
@@ -13,6 +14,8 @@ import {
deriveEscrowId,
} from '@solisting/sdk'
import { DESCRO_PROGRAM_ADDRESS } from '@descro/sdk'
import { ResolverType } from '@descro/sdk'
import { Button } from '@/components/ui/Button'
import type { Address } from '@solisting/sdk'
const SYSTEM_PROGRAM = '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>
@@ -24,7 +27,10 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
const { signer } = useKitTransactionSigner()
const sendTx = useTx()
const { data: listing } = useListing(pk as Address)
const { data: resolvers = [] } = useResolvers()
const [orderLoading, setOrderLoading] = useState(false)
const [showResolverPicker, setShowResolverPicker] = useState(false)
const [selectedResolver, setSelectedResolver] = useState<Address | null>(null)
async function handleClose() {
if (!signer) return
@@ -33,8 +39,14 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
router.push('/listings')
}
async function handlePlaceOrder() {
function openPlaceOrder() {
setSelectedResolver(null)
setShowResolverPicker(true)
}
async function handleConfirmOrder() {
if (!signer || !account || !listing) return
setShowResolverPicker(false)
setOrderLoading(true)
try {
const orderId = BigInt(Date.now())
@@ -50,7 +62,7 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
descroProgram: DESCRO_PROGRAM_ADDRESS,
orderId,
escrowId,
resolver: account as Address,
resolver: selectedResolver,
paymentCurrency: { __kind: 'Sol' },
expectedAmount: listing.data.price,
maxSlippageBps: 0,
@@ -60,20 +72,108 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
[['listing', pk], ['orders', pk]],
'create_order',
)
} catch (err) {
console.error('create_order failed:', err)
} catch {
// error already shown via toast in useTx
} finally {
setOrderLoading(false)
}
}
const acceptedResolvers = listing?.data.acceptedResolvers ?? []
const visibleResolvers = acceptedResolvers.length > 0
? resolvers.filter((r) => acceptedResolvers.includes(r.pda as Address))
: resolvers
const mustSelectResolver = acceptedResolvers.length > 0
const confirmDisabled = mustSelectResolver && !selectedResolver
return (
<>
<ListingDetail
pk={pk as Address}
walletAddress={account ?? null}
onUpdate={() => router.push(`/listing/create?edit=${pk}`)}
onClose={handleClose}
onPlaceOrder={orderLoading ? undefined : handlePlaceOrder}
onPlaceOrder={orderLoading ? undefined : openPlaceOrder}
/>
{showResolverPicker && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}
onClick={(e) => { if (e.target === e.currentTarget) setShowResolverPicker(false) }}
>
<div style={{ background: 'var(--bg)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '28px 28px 24px', width: 440, maxWidth: '90vw', maxHeight: '80vh', overflow: 'auto' }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 12 }}>PLACE ORDER</div>
<h2 style={{ margin: '0 0 8px', fontSize: 19, fontWeight: 700 }}>Select Resolver</h2>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--mut)', lineHeight: 1.55 }}>
{mustSelectResolver
? 'This listing requires one of the following resolvers for dispute resolution.'
: 'Choose a resolver for dispute resolution, or proceed without one.'}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 22 }}>
{!mustSelectResolver && (
<ResolverOption
selected={selectedResolver === null}
onClick={() => setSelectedResolver(null)}
label="No resolver"
sublabel="Disputes cannot be resolved on-chain"
/>
)}
{visibleResolvers.map((r) => (
<ResolverOption
key={r.pda}
selected={selectedResolver === r.pda}
onClick={() => setSelectedResolver(r.pda as Address)}
label={`${r.pda.slice(0, 8)}${r.pda.slice(-6)}`}
sublabel={ResolverType[r.data.resolverType as number] as string}
mono
/>
))}
{visibleResolvers.length === 0 && mustSelectResolver && (
<div style={{ fontSize: 13, color: 'var(--mut)', padding: '12px 0' }}>
No registered resolvers found for this listing.
</div>
)}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Button variant="ghost" onClick={() => setShowResolverPicker(false)}>Cancel</Button>
<Button onClick={handleConfirmOrder} disabled={confirmDisabled}>Place Order</Button>
</div>
</div>
</div>
)}
</>
)
}
function ResolverOption({ selected, onClick, label, sublabel, mono }: {
selected: boolean
onClick: () => void
label: string
sublabel?: string
mono?: boolean
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '11px 14px', borderRadius: 9, cursor: 'pointer', textAlign: 'left',
background: selected ? 'var(--accSoft)' : 'var(--bg3)',
border: `1px solid ${selected ? 'var(--accBd)' : 'var(--bd)'}`,
color: 'var(--tx)',
transition: 'background .12s, border-color .12s',
}}
>
<span style={{ fontFamily: mono ? 'var(--font-mono)' : undefined, fontSize: mono ? 12.5 : 13, fontWeight: selected ? 600 : mono ? 400 : 600 }}>
{label}
</span>
{sublabel && (
<span style={{ fontSize: 11.5, color: 'var(--mut)', flexShrink: 0 }}>
{sublabel}
</span>
)}
</button>
)
}

View File

@@ -17,8 +17,11 @@ import {
DESCRO_PROGRAM_ADDRESS,
findResolverEntryPda,
} from '@descro/sdk'
import { isSome } from '@solana/kit'
import type { Address } from '@solisting/sdk'
const SYSTEM_PROGRAM = '11111111111111111111111111111111' as Address
export default function OrderPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const { account } = useWallet()
@@ -31,10 +34,14 @@ export default function OrderPage({ params }: { params: Promise<{ pk: string }>
async function handleAccept() {
if (!signer || !data) return
const od = data.order.data
const [resolverEntry] = await findResolverEntryPda({ authority: od.resolver })
const resolverAddr = isSome(od.resolver) ? od.resolver.value : null
const resolverKey = resolverAddr ?? SYSTEM_PROGRAM
const [resolverEntry] = resolverAddr
? await findResolverEntryPda({ authority: resolverAddr })
: [SYSTEM_PROGRAM]
const ix = getAcceptOrderInstruction({
seller: signer,
resolver: od.resolver,
resolver: resolverKey,
listingAccount: od.listing,
orderAccount: pk as Address,
escrowAccount: od.escrowAccount,

View File

@@ -26,6 +26,8 @@ interface FormState {
price: string
quantity: string
oracle: string
name: string
description: string
metadataUri: string
alts: AltEntry[]
resolvers: string[]
@@ -39,6 +41,8 @@ function blank(): FormState {
price: '',
quantity: '',
oracle: '',
name: '',
description: '',
metadataUri: '',
alts: [],
resolvers: [],
@@ -87,6 +91,9 @@ function CurrencyToggle({
}) {
const active = (on: boolean): React.CSSProperties => ({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 11,
borderRadius: 10,
fontSize: 13,
@@ -94,7 +101,7 @@ function CurrencyToggle({
cursor: 'pointer',
color: on ? '#0b0613' : 'var(--mut)',
background: on ? 'linear-gradient(135deg,var(--acc),var(--acc2))' : 'var(--bg3)',
border: `1px solid ${on ? 'transparent' : 'var(--bd)'}`,
border: `1px solid var(--bd)`,
})
return (
<div style={{ display: 'flex', gap: 8 }}>
@@ -145,6 +152,8 @@ export function CreateListingForm({ editPk }: Props) {
: (Number(d.price) / 1e9).toString(),
quantity: String(d.quantity),
oracle: isSome(d.canonicalOracle) ? d.canonicalOracle.value : '',
name: d.name ?? '',
description: d.description ?? '',
metadataUri: d.metadataUri ?? '',
alts: d.altCurrencies.map((a) => ({
currency:
@@ -262,6 +271,8 @@ export function CreateListingForm({ editPk }: Props) {
altCurrencies,
acceptedResolvers,
quantity: quantityN,
name: form.name,
description: form.description,
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings'], ['listing', editPk]], 'update_listing')
@@ -275,6 +286,8 @@ export function CreateListingForm({ editPk }: Props) {
altCurrencies,
acceptedResolvers,
quantity: quantityN,
name: form.name,
description: form.description,
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings']], 'create_listing')
@@ -334,6 +347,16 @@ export function CreateListingForm({ editPk }: Props) {
gap: 22,
}}
>
{/* NAME + DESCRIPTION */}
<div>
<label style={LABEL}>NAME <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 64 chars)</span></label>
<input value={form.name} onChange={set('name')} placeholder="e.g. Mechanical Keyboard Kit" style={INPUT} maxLength={64} />
</div>
<div>
<label style={LABEL}>DESCRIPTION <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span></label>
<input value={form.description} onChange={set('description')} placeholder="Short description of what you're selling" style={INPUT} maxLength={256} />
</div>
{/* CANONICAL CURRENCY */}
<div>
<label style={LABEL}>CANONICAL CURRENCY</label>

View File

@@ -168,7 +168,7 @@ export function Dashboard() {
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontWeight: 600, fontSize: 15 }}>
{l.data.metadataUri || abbrev(l.address)}
{l.data.name || `Listing #${String(l.data.listingId)}`}
</span>
<Badge color={sc.color} bg={sc.bg}>
{l.data.isActive ? 'Active' : 'Inactive'}
@@ -182,7 +182,7 @@ export function Dashboard() {
fontFamily: 'var(--font-mono)',
}}
>
#{String(l.data.listingId)} · {fmtSol(l.data.price)} · {avail}/
{abbrev(l.address)} · {fmtSol(l.data.price)} · {avail}/
{String(l.data.quantity)} avail · {String(l.data.quantityReserved)} pending
</div>
</div>

View File

@@ -0,0 +1,110 @@
'use client'
import { useRouter } from 'next/navigation'
import { isSome } from '@solana/kit'
import { escrowStateLabel } from '@descro/sdk'
import { EscrowState } from '@descro/sdk/src/generated/descro/src/generated/types/escrowState'
import { useEscrow } from '@/hooks/useEscrow'
import { useOrderByEscrow } from '@/hooks/useOrderByEscrow'
import { EscrowStateMachine } from '@/components/EscrowStateMachine'
import { FieldRow, MonoChip } from '@/components/ui/FieldRow'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { abbrev, fmtSol } from '@/lib/format'
import type { Address } from '@solana/kit'
function escrowStatusKey(state: EscrowState): keyof typeof STATUS_COLORS {
switch (state) {
case EscrowState.AwaitingSellerConfirm: return 'awaitingConfirm'
case EscrowState.Active: return 'active'
case EscrowState.Disputed: return 'disputed'
case EscrowState.Complete: return 'complete'
default: return 'cancelled'
}
}
interface Props {
pda: Address
}
export function EscrowDetail({ pda }: Props) {
const router = useRouter()
const { data: escrow, isLoading, error } = useEscrow(pda)
const { data: order } = useOrderByEscrow(pda)
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading</div>
if (error || !escrow) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Escrow account not found.</div>
const ed = escrow.data
const stateLabel = escrowStateLabel(ed.state)
const sc = STATUS_COLORS[escrowStatusKey(ed.state)]
const resolverPk = isSome(ed.disputeResolver) ? ed.disputeResolver.value : null
return (
<div>
<button
onClick={() => router.push('/escrows')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}
>
Escrows
</button>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 5 }}>
ESCROW ACCOUNT · #{String(ed.escrowId)}
</div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>{abbrev(pda)}</h1>
</div>
<Badge color={sc.color} bg={sc.bg}>{stateLabel}</Badge>
</div>
<EscrowStateMachine state={ed.state} />
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '8px 22px 16px' }}>
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '14px 0 4px' }}>
ESCROW ACCOUNT · descro
</div>
<FieldRow label="Address">
<MonoChip value={abbrev(pda)} onCopy={() => navigator.clipboard.writeText(pda)} />
</FieldRow>
<FieldRow label="State">
<Badge color={sc.color} bg={sc.bg}>{stateLabel}</Badge>
</FieldRow>
<FieldRow label="Escrow ID">
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>#{String(ed.escrowId)}</span>
</FieldRow>
<FieldRow label="Seller">
<MonoChip value={abbrev(ed.seller)} onCopy={() => navigator.clipboard.writeText(ed.seller)} />
</FieldRow>
<FieldRow label="Buyer">
<MonoChip value={abbrev(ed.buyer)} onCopy={() => navigator.clipboard.writeText(ed.buyer)} />
</FieldRow>
<FieldRow label="Amount">
<span style={{ fontSize: 13.5, fontWeight: 600 }}>{fmtSol(ed.amount)}</span>
</FieldRow>
{resolverPk && (
<FieldRow label="Resolver">
<MonoChip
value={abbrev(resolverPk)}
onClick={() => router.push(`/resolver/${resolverPk}`)}
onCopy={() => navigator.clipboard.writeText(resolverPk)}
/>
</FieldRow>
)}
<FieldRow label="Linked Order" last>
{order
? (
<MonoChip
value={abbrev(order.address)}
onClick={() => router.push(`/order/${order.address}`)}
onCopy={() => navigator.clipboard.writeText(order.address)}
/>
)
: <span style={{ fontSize: 13, color: 'var(--mut)' }}></span>
}
</FieldRow>
</div>
</div>
)
}

View File

@@ -82,8 +82,8 @@ export function EscrowsTable() {
borderRadius: 7,
fontSize: 12.5,
fontWeight: 600,
color: filter === f.key ? 'var(--tx)' : 'var(--mut)',
background: filter === f.key ? 'var(--bg2)' : 'transparent',
color: filter === f.key ? '#0b0613' : 'var(--mut)',
background: filter === f.key ? 'linear-gradient(135deg,var(--acc),var(--acc2))' : 'transparent',
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
@@ -137,7 +137,7 @@ export function EscrowsTable() {
return (
<div
key={e.pda}
onClick={() => router.push(`/search?q=${e.pda}`)}
onClick={() => router.push(`/escrow/${e.pda}`)}
style={{
display: 'grid',
gridTemplateColumns: '1.2fr 1.4fr 1.4fr .9fr 1.3fr .9fr',

View File

@@ -54,30 +54,40 @@ export function ListingDetail({ pk, walletAddress, onUpdate, onClose, onPlaceOrd
LISTING ACCOUNT · #{String(d.listingId)}
</div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
{d.metadataUri || abbrev(pk)}
{d.name || abbrev(pk)}
</h1>
</div>
<Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
{(d.description || d.metadataUri) && (
<p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--mut)', maxWidth: 620, lineHeight: 1.55 }}>
{d.description || (
<a href={d.metadataUri} target="_blank" rel="noreferrer" style={{ color: 'var(--acc2light)' }}>{d.metadataUri}</a>
)}
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: 18, alignItems: 'start', marginBottom: 24 }}>
{/* Left: account fields */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '8px 22px 16px' }}>
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '14px 0 4px' }}>ACCOUNT FIELDS</div>
<FieldRow label="Listing ID"><span style={{ fontSize: 13.5, fontWeight: 600 }}>#{String(d.listingId)}</span></FieldRow>
<FieldRow label="Address"><MonoChip value={abbrev(pk)} onCopy={() => navigator.clipboard.writeText(pk)} /></FieldRow>
<FieldRow label="Seller"><MonoChip value={abbrev(d.seller)} onCopy={() => navigator.clipboard.writeText(d.seller)} onClick={() => router.push(`/search?q=${d.seller}`)} /></FieldRow>
<FieldRow label="Price"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{fmtListingPrice()}</span></FieldRow>
<FieldRow label="Currency">
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>
{d.canonicalCurrency.__kind === 'Sol' ? 'SOL (native)' : `SPL · ${abbrev((d.canonicalCurrency as { mint: string }).mint)}`}
<FieldRow label="Listing ID"><span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>#{String(d.listingId)}</span></FieldRow>
<FieldRow label="Canonical Currency">
<span style={{ fontWeight: 600, fontSize: 13 }}>
{d.canonicalCurrency.__kind === 'Sol' ? 'SOL' : `SPL · ${abbrev((d.canonicalCurrency as { mint: string }).mint)}`}
</span>
</FieldRow>
{canonicalOracleAddr && (
<FieldRow label="Oracle">
<MonoChip value={abbrev(canonicalOracleAddr)} onCopy={() => navigator.clipboard.writeText(canonicalOracleAddr)} />
<FieldRow label="Price"><span style={{ fontWeight: 700, fontSize: 13.5 }}>{fmtListingPrice()}</span></FieldRow>
<FieldRow label="Canonical Oracle">
{canonicalOracleAddr
? <MonoChip value={abbrev(canonicalOracleAddr)} onCopy={() => navigator.clipboard.writeText(canonicalOracleAddr)} />
: <span style={{ fontSize: 13, color: 'var(--mut)' }}>None priced directly</span>
}
</FieldRow>
)}
<FieldRow label="Status"><Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge></FieldRow>
<FieldRow label="Bump"><span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--mut)' }}>{d.bump}</span></FieldRow>
{d.metadataUri && (
<>
@@ -137,7 +147,7 @@ export function ListingDetail({ pk, walletAddress, onUpdate, onClose, onPlaceOrd
<Button variant="danger" onClick={onClose}>Close Listing</Button>
</div>
)}
{canPlace && <Button onClick={onPlaceOrder}>Place Order</Button>}
{canPlace && <Button style={{ width: '100%', padding: 11 }} onClick={onPlaceOrder}>Place Order</Button>}
{!walletAddress && <div style={{ fontSize: 13, color: 'var(--mut)', lineHeight: 1.5 }}>Connect a wallet to place an order or manage this listing.</div>}
{walletAddress && !isSeller && !canPlace && <div style={{ fontSize: 13, color: 'var(--mut)', lineHeight: 1.5 }}>No actions available listing is inactive or out of stock.</div>}
</div>

View File

@@ -59,15 +59,27 @@ export function ListingsTable() {
style={{ height: 38, width: 220, padding: '0 13px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, fontSize: 13, fontFamily: 'var(--font-mono)', outline: 'none' }}
/>
<div style={{ display: 'flex', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 3 }}>
{FILTERS.map((f) => (
{FILTERS.map((f) => {
const active = filter === f.key
return (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{ padding: '6px 13px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, color: filter === f.key ? 'var(--tx)' : 'var(--mut)', background: filter === f.key ? 'var(--bg2)' : 'transparent' }}
style={{
padding: '6px 13px',
borderRadius: 7,
fontSize: 12.5,
fontWeight: 600,
color: active ? '#0b0613' : 'var(--mut)',
background: active ? 'linear-gradient(135deg,var(--acc),var(--acc2))' : 'transparent',
border: 'none',
cursor: 'pointer',
}}
>
{f.label}
</button>
))}
)
})}
</div>
</div>
</div>
@@ -96,7 +108,7 @@ export function ListingsTable() {
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{l.data.metadataUri || abbrev(l.address)}
{l.data.name || abbrev(l.address)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--mut)', marginTop: 3 }}>
#{String(l.data.listingId)} · {abbrev(l.address)}

View File

@@ -147,6 +147,9 @@ export function Nav() {
})}
</div>
{/* Spacer — pushes right-side controls to the edge */}
<div style={{ flex: 1 }} />
{/* Network picker */}
<div ref={netRef} style={{ position: 'relative', flexShrink: 0 }}>
<button

View File

@@ -44,7 +44,7 @@ export function OrderDetail({ pk, walletAddress, onAccept, onReject, onCancel, o
const od = order.data
const ed = escrow?.data
const stateEnum = ed?.state ?? EscrowState.Cancelled
const stateEnum = ed?.state ?? EscrowState.Complete
const stateLabel = escrowStateLabel(stateEnum)
const sc = STATUS_COLORS[escrowStatusKey(stateEnum)]

View File

@@ -36,6 +36,9 @@ export function Button({ variant = 'primary', children, style, disabled, ...rest
{...rest}
disabled={disabled}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '11px 18px',
borderRadius: 'var(--radius)',
fontWeight: 700,

View File

@@ -2,24 +2,26 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
const ToastCtx = createContext<(msg: string) => void>(() => {})
type ToastFn = (msg: string, type?: 'success' | 'error') => void
const ToastCtx = createContext<ToastFn>(() => {})
export function useToast() {
return useContext(ToastCtx)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [msg, setMsg] = useState('')
const [state, setState] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
const show = useCallback((m: string) => {
setMsg(m)
setTimeout(() => setMsg(''), 2800)
const show = useCallback<ToastFn>((m, type = 'success') => {
setState({ msg: m, type })
setTimeout(() => setState(null), type === 'error' ? 4500 : 2800)
}, [])
return (
<ToastCtx.Provider value={show}>
{children}
{msg && (
{state && (
<div
style={{
position: 'fixed',
@@ -32,7 +34,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
gap: 10,
padding: '12px 18px',
background: 'var(--bg2)',
border: '1px solid var(--accBd)',
border: `1px solid ${state.type === 'error' ? 'var(--errBd, #f87171)' : 'var(--accBd)'}`,
borderRadius: 12,
boxShadow: '0 16px 40px rgba(0,0,0,.55)',
fontSize: 13,
@@ -46,12 +48,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--acc2)',
boxShadow: '0 0 8px var(--acc2)',
background: state.type === 'error' ? '#f87171' : 'var(--acc2)',
boxShadow: state.type === 'error' ? '0 0 8px #f87171' : '0 0 8px var(--acc2)',
flexShrink: 0,
}}
/>
{msg}
{state.msg}
</div>
)}
</ToastCtx.Provider>

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchEscrowAccount } from '@descro/sdk'
import type { Address } from '@solana/kit'
export function useEscrow(pda: Address) {
const { cluster } = useCluster()
return useQuery({
queryKey: ['escrow', pda, cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchEscrowAccount(
rpc as Parameters<typeof fetchEscrowAccount>[0],
pda,
).catch(() => null)
},
enabled: !!cluster && !!pda,
refetchInterval: 15_000,
})
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchOrderByEscrow } from '@solisting/sdk'
import type { Address } from '@solana/kit'
export function useOrderByEscrow(escrowPda: Address) {
const { cluster } = useCluster()
return useQuery({
queryKey: ['orderByEscrow', escrowPda, cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchOrderByEscrow(rpc, escrowPda)
},
enabled: !!cluster && !!escrowPda,
refetchInterval: 30_000,
})
}

View File

@@ -39,10 +39,15 @@ export function useTx() {
const signed = await signTransactionMessageWithSigners(txMsg)
assertIsTransactionWithBlockhashLifetime(signed)
try {
await sendAndConfirmTransactionFactory({ rpc: rpc as never, rpcSubscriptions: rpcSubscriptions as never })(
signed as never,
{ commitment: 'confirmed' },
)
} catch (err) {
toast(friendlyTxError(err), 'error')
throw err
}
toast(`Tx confirmed — ${label}`)
for (const key of invalidateKeys) {
@@ -50,3 +55,20 @@ export function useTx() {
}
}
}
function friendlyTxError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err)
if (
msg.includes('Attempt to debit an account but found no record of a prior credit') ||
msg.includes('AccountNotFound')
) {
return 'Insufficient SOL — please fund your wallet and try again.'
}
if (msg.includes('insufficient lamports') || msg.includes('insufficient funds')) {
return 'Insufficient SOL — please fund your wallet and try again.'
}
if (msg.includes('User rejected') || msg.includes('Transaction was not confirmed')) {
return 'Transaction cancelled.'
}
return 'Transaction failed — please try again.'
}

View File

@@ -23,7 +23,6 @@ pub struct AcceptOrder<'info> {
seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
bump = order_account.bump,
constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
close = seller,
)]
pub order_account: Account<'info, OrderAccount>,

View File

@@ -29,6 +29,8 @@ pub fn handler(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
@@ -40,6 +42,8 @@ pub fn handler(
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.quantity_reserved = 0;
listing.name = name;
listing.description = description;
listing.metadata_uri = metadata_uri;
listing.listing_id = listing_id;
listing.is_active = true;

View File

@@ -4,7 +4,7 @@ use crate::state::{Currency, ListingAccount, OrderAccount};
use anchor_lang::prelude::*;
#[derive(Accounts)]
#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: Currency)]
#[instruction(order_id: u64, escrow_id: u64, resolver: Option<Pubkey>, payment_currency: Currency)]
pub struct CreateOrder<'info> {
#[account(mut)]
pub buyer: Signer<'info>,
@@ -67,7 +67,7 @@ pub fn handler(
ctx: Context<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
payment_currency: Currency,
expected_amount: u64,
max_slippage_bps: u16,
@@ -80,10 +80,13 @@ pub fn handler(
let listing = &ctx.accounts.listing_account;
if !listing.accepted_resolvers.is_empty() {
require!(
listing.accepted_resolvers.contains(&resolver),
match resolver {
Some(r) => require!(
listing.accepted_resolvers.contains(&r),
SolistingError::ResolverNotAccepted
);
),
None => return err!(SolistingError::ResolverNotAccepted),
}
}
let amount = if payment_currency == listing.canonical_currency {
@@ -143,7 +146,7 @@ pub fn handler(
},
),
amount,
Some(resolver),
resolver,
escrow_id,
)?;

View File

@@ -25,6 +25,8 @@ pub fn handler(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
@@ -34,6 +36,8 @@ pub fn handler(
listing.alt_currencies = alt_currencies;
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.name = name;
listing.description = description;
listing.metadata_uri = metadata_uri;
Ok(())
}

View File

@@ -31,11 +31,14 @@ pub mod solisting {
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
create_listing::handler(
ctx, listing_id, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
canonical_oracle, alt_currencies, accepted_resolvers, quantity,
name, description, metadata_uri,
)
}
@@ -48,11 +51,14 @@ pub mod solisting {
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
update_listing::handler(
ctx, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
canonical_oracle, alt_currencies, accepted_resolvers, quantity,
name, description, metadata_uri,
)
}
@@ -64,7 +70,7 @@ pub mod solisting {
ctx: Context<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
payment_currency: state::Currency,
expected_amount: u64,
max_slippage_bps: u16,

View File

@@ -52,6 +52,10 @@ pub struct ListingAccount {
pub quantity: u32,
/// Units held by pending orders. available = quantity - quantity_reserved.
pub quantity_reserved: u32,
#[max_len(64)]
pub name: String,
#[max_len(256)]
pub description: String,
#[max_len(256)]
pub metadata_uri: String,
pub listing_id: u64,
@@ -65,7 +69,7 @@ pub struct OrderAccount {
pub listing: Pubkey,
pub buyer: Pubkey,
pub seller: Pubkey,
pub resolver: Pubkey,
pub resolver: Option<Pubkey>,
/// The currency the buyer chose to pay in.
pub payment_currency: Currency,
/// Actual amount paid (may differ from price when oracle-converted).

View File

@@ -101,6 +101,8 @@ pub fn ix_create_listing(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Instruction {
let listing_account = listing_pda(seller, listing_id);
@@ -114,6 +116,8 @@ pub fn ix_create_listing(
alt_currencies,
accepted_resolvers,
quantity,
name,
description,
metadata_uri,
}
.data(),
@@ -136,6 +140,8 @@ pub fn ix_update_listing(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Instruction {
let listing_account = listing_pda(seller, listing_id);
@@ -148,6 +154,8 @@ pub fn ix_update_listing(
alt_currencies,
accepted_resolvers,
quantity,
name,
description,
metadata_uri,
}
.data(),
@@ -187,7 +195,6 @@ pub fn ix_create_order(
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
) -> Instruction {
let resolver = Pubkey::new_unique();
ix_create_order_with_resolver(
buyer,
seller,
@@ -197,7 +204,7 @@ pub fn ix_create_order(
max_slippage_bps,
canonical_oracle,
target_oracle,
resolver,
Some(Pubkey::new_unique()),
)
}
@@ -211,7 +218,7 @@ pub fn ix_create_order_with_resolver(
max_slippage_bps: u16,
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
resolver: Pubkey,
resolver: Option<Pubkey>,
) -> Instruction {
let listing = listing_pda(seller, listing_id);
let order = order_pda(listing, *buyer, order_id);
@@ -256,7 +263,7 @@ pub fn ix_accept_order(
buyer: &Pubkey,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
) -> Instruction {
let listing_account = listing_pda(seller, listing_id);
let order_account = order_pda(listing_account, *buyer, order_id);
@@ -268,7 +275,7 @@ pub fn ix_accept_order(
&solisting::instruction::AcceptOrder {}.data(),
solisting::accounts::AcceptOrder {
seller: *seller,
resolver,
resolver: resolver.unwrap_or(system_program::ID),
listing_account,
order_account,
escrow_account,

View File

@@ -17,7 +17,7 @@ fn seller_can_create_sol_only_listing() {
vec![],
vec![],
10,
"ipfs://test".to_string(),
"".to_string(), "".to_string(), "ipfs://test".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -48,7 +48,7 @@ fn seller_can_create_listing_with_usdc_alt_stablecoin() {
}],
vec![],
5,
"ipfs://x".to_string(),
"".to_string(), "".to_string(), "ipfs://x".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -75,7 +75,7 @@ fn seller_can_create_listing_with_bonk_alt_oracle() {
}],
vec![],
5,
"".to_string(),
"".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -90,7 +90,7 @@ fn seller_can_update_listing() {
let listing_id: u64 = 2;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000,
None, vec![], vec![], 10, "".to_string(),
None, vec![], vec![], 10, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -103,7 +103,7 @@ fn seller_can_update_listing() {
vec![],
vec![],
20,
"ipfs://new".to_string(),
"".to_string(), "".to_string(), "ipfs://new".to_string(),
);
send(&mut svm, &[ix_update], &[&seller]);
@@ -118,7 +118,7 @@ fn seller_can_close_listing() {
let listing_id: u64 = 3;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000,
None, vec![], vec![], 5, "".to_string(),
None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);

View File

@@ -10,7 +10,7 @@ fn buyer_can_create_order_canonical_sol() {
let price = 100_000_000u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, price,
None, vec![], vec![], 5, "".to_string(),
None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -42,7 +42,7 @@ fn create_order_fails_if_listing_inactive() {
let listing_id = 2u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000,
None, vec![], vec![], 1, "".to_string(),
None, vec![], vec![], 1, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
let ix_close = ix_close_listing(&seller.pubkey(), listing_id);
@@ -62,7 +62,7 @@ fn create_order_fails_if_out_of_stock() {
let listing_id = 3u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 100_000_000,
None, vec![], vec![], 1, "".to_string(),
None, vec![], vec![], 1, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
send(
@@ -87,7 +87,7 @@ fn create_order_fails_if_currency_not_accepted() {
let listing_id = 4u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 100_000_000,
None, vec![], vec![], 5, "".to_string(),
None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -110,7 +110,7 @@ fn create_order_fails_if_resolver_not_accepted() {
let allowed_resolver = Pubkey::new_unique();
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 100_000_000,
None, vec![], vec![allowed_resolver], 5, "".to_string(),
None, vec![], vec![allowed_resolver], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -118,7 +118,7 @@ fn create_order_fails_if_resolver_not_accepted() {
&mut svm,
&[ix_create_order_with_resolver(
&buyer.pubkey(), &seller.pubkey(), listing_id, 1,
Currency::Sol, 0, None, None, Pubkey::new_unique(),
Currency::Sol, 0, None, None, Some(Pubkey::new_unique()),
)],
&[&buyer],
);
@@ -132,7 +132,7 @@ fn seller_accept_creates_active_descro_escrow() {
let price = 100_000_000u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
let order_id = 1u64;
@@ -152,7 +152,7 @@ fn seller_accept_creates_active_descro_escrow() {
);
let order_key = order_pda(listing_key, buyer.pubkey(), order_id);
assert!(svm.get_account(&order_key).is_none());
assert!(svm.get_account(&order_key).is_some());
let listing = read_listing(&svm, &seller.pubkey(), listing_id);
assert_eq!(listing.quantity_reserved, 0);
@@ -166,7 +166,7 @@ fn seller_can_reject_order() {
let price = 100_000_000u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -197,7 +197,7 @@ fn buyer_can_cancel_order() {
let listing_id = 21u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -223,7 +223,7 @@ fn reject_handles_already_cancelled_escrow() {
let listing_id = 22u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -250,7 +250,7 @@ fn anyone_can_close_stale_order_after_terminal_escrow() {
let listing_id = 30u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -284,7 +284,7 @@ fn close_stale_order_fails_if_escrow_still_active() {
let listing_id = 31u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(

View File

@@ -96,6 +96,8 @@ export type SolistingStateListingAccount = {
quantity: number;
/** Units held by pending orders. available = quantity - quantity_reserved. */
quantityReserved: number;
name: string;
description: string;
metadataUri: string;
listingId: bigint;
isActive: boolean;
@@ -125,6 +127,8 @@ export type SolistingStateListingAccountArgs = {
quantity: number;
/** Units held by pending orders. available = quantity - quantity_reserved. */
quantityReserved: number;
name: string;
description: string;
metadataUri: string;
listingId: number | bigint;
isActive: boolean;
@@ -147,6 +151,8 @@ export function getSolistingStateListingAccountEncoder(): Encoder<SolistingState
["acceptedResolvers", getArrayEncoder(getAddressEncoder())],
["quantity", getU32Encoder()],
["quantityReserved", getU32Encoder()],
["name", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["description", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["metadataUri", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["listingId", getU64Encoder()],
["isActive", getBooleanEncoder()],
@@ -174,6 +180,8 @@ export function getSolistingStateListingAccountDecoder(): Decoder<SolistingState
["acceptedResolvers", getArrayDecoder(getAddressDecoder())],
["quantity", getU32Decoder()],
["quantityReserved", getU32Decoder()],
["name", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["description", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["metadataUri", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["listingId", getU64Decoder()],
["isActive", getBooleanDecoder()],

View File

@@ -21,6 +21,8 @@ import {
getBytesEncoder,
getI64Decoder,
getI64Encoder,
getOptionDecoder,
getOptionEncoder,
getStructDecoder,
getStructEncoder,
getU64Decoder,
@@ -38,6 +40,8 @@ import {
type FetchAccountsConfig,
type MaybeAccount,
type MaybeEncodedAccount,
type Option,
type OptionOrNullable,
type ReadonlyUint8Array,
} from "@solana/kit";
import {
@@ -61,7 +65,7 @@ export type SolistingStateOrderAccount = {
listing: Address;
buyer: Address;
seller: Address;
resolver: Address;
resolver: Option<Address>;
/** The currency the buyer chose to pay in. */
paymentCurrency: SolistingStateCurrency;
/** Actual amount paid (may differ from price when oracle-converted). */
@@ -79,7 +83,7 @@ export type SolistingStateOrderAccountArgs = {
listing: Address;
buyer: Address;
seller: Address;
resolver: Address;
resolver: OptionOrNullable<Address>;
/** The currency the buyer chose to pay in. */
paymentCurrency: SolistingStateCurrencyArgs;
/** Actual amount paid (may differ from price when oracle-converted). */
@@ -101,7 +105,7 @@ export function getSolistingStateOrderAccountEncoder(): Encoder<SolistingStateOr
["listing", getAddressEncoder()],
["buyer", getAddressEncoder()],
["seller", getAddressEncoder()],
["resolver", getAddressEncoder()],
["resolver", getOptionEncoder(getAddressEncoder())],
["paymentCurrency", getSolistingStateCurrencyEncoder()],
["amount", getU64Encoder()],
["escrowAccount", getAddressEncoder()],
@@ -124,7 +128,7 @@ export function getSolistingStateOrderAccountDecoder(): Decoder<SolistingStateOr
["listing", getAddressDecoder()],
["buyer", getAddressDecoder()],
["seller", getAddressDecoder()],
["resolver", getAddressDecoder()],
["resolver", getOptionDecoder(getAddressDecoder())],
["paymentCurrency", getSolistingStateCurrencyDecoder()],
["amount", getU64Decoder()],
["escrowAccount", getAddressDecoder()],

View File

@@ -111,6 +111,8 @@ export type CreateListingInstructionData = {
altCurrencies: Array<SolistingStateAltCurrencyConfig>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -122,6 +124,8 @@ export type CreateListingInstructionDataArgs = {
altCurrencies: Array<SolistingStateAltCurrencyConfigArgs>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -139,6 +143,8 @@ export function getCreateListingInstructionDataEncoder(): Encoder<CreateListingI
],
["acceptedResolvers", getArrayEncoder(getAddressEncoder())],
["quantity", getU32Encoder()],
["name", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["description", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["metadataUri", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
]),
(value) => ({ ...value, discriminator: CREATE_LISTING_DISCRIMINATOR }),
@@ -158,6 +164,8 @@ export function getCreateListingInstructionDataDecoder(): Decoder<CreateListingI
],
["acceptedResolvers", getArrayDecoder(getAddressDecoder())],
["quantity", getU32Decoder()],
["name", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["description", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["metadataUri", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
]);
}
@@ -187,6 +195,8 @@ export type CreateListingAsyncInput<
altCurrencies: CreateListingInstructionDataArgs["altCurrencies"];
acceptedResolvers: CreateListingInstructionDataArgs["acceptedResolvers"];
quantity: CreateListingInstructionDataArgs["quantity"];
name: CreateListingInstructionDataArgs["name"];
description: CreateListingInstructionDataArgs["description"];
metadataUri: CreateListingInstructionDataArgs["metadataUri"];
};
@@ -279,6 +289,8 @@ export type CreateListingInput<
altCurrencies: CreateListingInstructionDataArgs["altCurrencies"];
acceptedResolvers: CreateListingInstructionDataArgs["acceptedResolvers"];
quantity: CreateListingInstructionDataArgs["quantity"];
name: CreateListingInstructionDataArgs["name"];
description: CreateListingInstructionDataArgs["description"];
metadataUri: CreateListingInstructionDataArgs["metadataUri"];
};

View File

@@ -14,6 +14,8 @@ import {
getAddressEncoder,
getBytesDecoder,
getBytesEncoder,
getOptionDecoder,
getOptionEncoder,
getProgramDerivedAddress,
getStructDecoder,
getStructEncoder,
@@ -33,6 +35,8 @@ import {
type Instruction,
type InstructionWithAccounts,
type InstructionWithData,
type Option,
type OptionOrNullable,
type ReadonlyAccount,
type ReadonlyUint8Array,
type TransactionSigner,
@@ -121,7 +125,7 @@ export type CreateOrderInstructionData = {
discriminator: ReadonlyUint8Array;
orderId: bigint;
escrowId: bigint;
resolver: Address;
resolver: Option<Address>;
paymentCurrency: SolistingStateCurrency;
expectedAmount: bigint;
maxSlippageBps: number;
@@ -130,7 +134,7 @@ export type CreateOrderInstructionData = {
export type CreateOrderInstructionDataArgs = {
orderId: number | bigint;
escrowId: number | bigint;
resolver: Address;
resolver: OptionOrNullable<Address>;
paymentCurrency: SolistingStateCurrencyArgs;
expectedAmount: number | bigint;
maxSlippageBps: number;
@@ -142,7 +146,7 @@ export function getCreateOrderInstructionDataEncoder(): Encoder<CreateOrderInstr
["discriminator", fixEncoderSize(getBytesEncoder(), 8)],
["orderId", getU64Encoder()],
["escrowId", getU64Encoder()],
["resolver", getAddressEncoder()],
["resolver", getOptionEncoder(getAddressEncoder())],
["paymentCurrency", getSolistingStateCurrencyEncoder()],
["expectedAmount", getU64Encoder()],
["maxSlippageBps", getU16Encoder()],
@@ -156,7 +160,7 @@ export function getCreateOrderInstructionDataDecoder(): Decoder<CreateOrderInstr
["discriminator", fixDecoderSize(getBytesDecoder(), 8)],
["orderId", getU64Decoder()],
["escrowId", getU64Decoder()],
["resolver", getAddressDecoder()],
["resolver", getOptionDecoder(getAddressDecoder())],
["paymentCurrency", getSolistingStateCurrencyDecoder()],
["expectedAmount", getU64Decoder()],
["maxSlippageBps", getU16Decoder()],

View File

@@ -101,6 +101,8 @@ export type UpdateListingInstructionData = {
altCurrencies: Array<SolistingStateAltCurrencyConfig>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -111,6 +113,8 @@ export type UpdateListingInstructionDataArgs = {
altCurrencies: Array<SolistingStateAltCurrencyConfigArgs>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -127,6 +131,8 @@ export function getUpdateListingInstructionDataEncoder(): Encoder<UpdateListingI
],
["acceptedResolvers", getArrayEncoder(getAddressEncoder())],
["quantity", getU32Encoder()],
["name", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["description", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["metadataUri", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
]),
(value) => ({ ...value, discriminator: UPDATE_LISTING_DISCRIMINATOR }),
@@ -145,6 +151,8 @@ export function getUpdateListingInstructionDataDecoder(): Decoder<UpdateListingI
],
["acceptedResolvers", getArrayDecoder(getAddressDecoder())],
["quantity", getU32Decoder()],
["name", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["description", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["metadataUri", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
]);
}
@@ -171,6 +179,8 @@ export type UpdateListingInput<
altCurrencies: UpdateListingInstructionDataArgs["altCurrencies"];
acceptedResolvers: UpdateListingInstructionDataArgs["acceptedResolvers"];
quantity: UpdateListingInstructionDataArgs["quantity"];
name: UpdateListingInstructionDataArgs["name"];
description: UpdateListingInstructionDataArgs["description"];
metadataUri: UpdateListingInstructionDataArgs["metadataUri"];
};

View File

@@ -115,6 +115,14 @@
"name": "quantity",
"type": "u32"
},
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "metadata_uri",
"type": "string"
@@ -208,6 +216,14 @@
"name": "quantity",
"type": "u32"
},
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "metadata_uri",
"type": "string"
@@ -1337,6 +1353,14 @@
],
"type": "u32"
},
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "metadata_uri",
"type": "string"

View File

@@ -7,7 +7,7 @@ export * from './listing'
// Order exports are imported but we exclude the re-exported deriveEscrowId to avoid duplication
// since it's already exported from pda.js
export type { OrderAccountWithPda } from './order'
export { fetchOrdersForListing, fetchOrdersByBuyer, fetchOrder } from './order'
export { fetchOrdersForListing, fetchOrdersByBuyer, fetchOrderByEscrow, fetchOrder } from './order'
// Re-export Address for consumers
export type { Address, Account } from '@solana/kit'

View File

@@ -1,11 +1,11 @@
import { address, type Account, type Address, type Rpc } from '@solana/kit'
import { type GetProgramAccountsApi } from '@solana/rpc-api'
import { type GetAccountInfoApi } from '@solana/rpc-api'
import type { Base64EncodedBytes } from '@solana/rpc-types'
import type { Base64EncodedBytes, Lamports } from '@solana/rpc-types'
import {
decodeSolistingStateOrderAccount,
fetchSolistingStateOrderAccount,
SOLISTING_STATE_ORDER_ACCOUNT_DISCRIMINATOR,
getSolistingStateOrderAccountDiscriminatorBytes,
type SolistingStateOrderAccount,
} from './generated/solisting/src/generated/index'
import { deriveEscrowId } from './pda'
@@ -16,22 +16,57 @@ export type OrderAccountWithPda = Account<SolistingStateOrderAccount>
type GpaRpc = Rpc<GetProgramAccountsApi>
type GetRpc = Rpc<GetAccountInfoApi>
async function fetchAllOrders(rpc: GpaRpc): Promise<OrderAccountWithPda[]> {
const disc = SOLISTING_STATE_ORDER_ACCOUNT_DISCRIMINATOR
const discBase64 = Buffer.from(disc).toString('base64') as Base64EncodedBytes
// OrderAccount byte layout (Sol-currency variant, the only one currently implemented):
// 0: discriminator (8)
// 8: listing (32)
// 40: buyer (32)
// 72: seller (32)
// 104: resolver Option<Pubkey> = 1 byte tag + 32 bytes = 33 bytes
// 137: paymentCurrency — Sol variant = 1 byte tag + 0 data = 1 byte total
// 138: amount (8)
// 146: escrowAccount (32)
const LISTING_OFFSET = 8n
const BUYER_OFFSET = 40n
// escrowAccount offset is only valid for Sol-currency orders.
// SPL orders (currently rejected by the program with SplNotImplemented) would sit at offset 179.
const ESCROW_ACCOUNT_OFFSET = 146n
type RawGpaResult = Array<{
pubkey: Address
account: {
executable: boolean
lamports: bigint
owner: Address
space: bigint
data: [string, 'base64']
}
}>
const DISC_BASE64 = Buffer.from(
getSolistingStateOrderAccountDiscriminatorBytes(),
).toString('base64') as Base64EncodedBytes
async function gpa(
rpc: GpaRpc,
addressFilter: { offset: bigint; addr: Address },
): Promise<OrderAccountWithPda[]> {
const results = await rpc
.getProgramAccounts(PROGRAM_ADDRESS, {
encoding: 'base64',
filters: [{ memcmp: { offset: 0n, bytes: discBase64, encoding: 'base64' } }],
filters: [
{ memcmp: { offset: 0n, bytes: DISC_BASE64, encoding: 'base64' } },
{ memcmp: { offset: addressFilter.offset, bytes: addressFilter.addr as never, encoding: 'base58' } },
],
})
.send()
return (results as Array<{ pubkey: Address; account: { executable: boolean; lamports: bigint; owner: Address; space: bigint; data: [string, 'base64'] } }>).map((r) => {
return (results as RawGpaResult).map((r) => {
const data = new Uint8Array(Buffer.from(r.account.data[0], 'base64'))
return decodeSolistingStateOrderAccount({
address: r.pubkey,
data,
executable: r.account.executable,
lamports: r.account.lamports as unknown as import('@solana/rpc-types').Lamports,
lamports: r.account.lamports as unknown as Lamports,
programAddress: r.account.owner,
space: r.account.space,
exists: true,
@@ -39,26 +74,20 @@ async function fetchAllOrders(rpc: GpaRpc): Promise<OrderAccountWithPda[]> {
})
}
export async function fetchOrdersForListing(
rpc: GpaRpc,
listingPk: Address,
): Promise<OrderAccountWithPda[]> {
const all = await fetchAllOrders(rpc)
return all.filter((o) => o.data.listing === listingPk)
export function fetchOrdersForListing(rpc: GpaRpc, listingPk: Address): Promise<OrderAccountWithPda[]> {
return gpa(rpc, { offset: LISTING_OFFSET, addr: listingPk })
}
export async function fetchOrdersByBuyer(
rpc: GpaRpc,
buyer: Address,
): Promise<OrderAccountWithPda[]> {
const all = await fetchAllOrders(rpc)
return all.filter((o) => o.data.buyer === buyer)
export function fetchOrdersByBuyer(rpc: GpaRpc, buyer: Address): Promise<OrderAccountWithPda[]> {
return gpa(rpc, { offset: BUYER_OFFSET, addr: buyer })
}
export async function fetchOrder(
rpc: GetRpc,
addr: Address,
): Promise<OrderAccountWithPda | null> {
export async function fetchOrderByEscrow(rpc: GpaRpc, escrowPda: Address): Promise<OrderAccountWithPda | null> {
const results = await gpa(rpc, { offset: ESCROW_ACCOUNT_OFFSET, addr: escrowPda })
return results[0] ?? null
}
export async function fetchOrder(rpc: GetRpc, addr: Address): Promise<OrderAccountWithPda | null> {
return fetchSolistingStateOrderAccount(rpc, addr).catch(() => null)
}

File diff suppressed because it is too large Load Diff