fix escrow handling and fetching
This commit is contained in:
8
app/src/app/escrow/[pk]/page.tsx
Normal file
8
app/src/app/escrow/[pk]/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
110
app/src/components/EscrowDetail.tsx
Normal file
110
app/src/components/EscrowDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ export function EscrowsTable() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={e.pda}
|
key={e.pda}
|
||||||
onClick={() => router.push(`/search?q=${e.pda}`)}
|
onClick={() => router.push(`/escrow/${e.pda}`)}
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1.2fr 1.4fr 1.4fr .9fr 1.3fr .9fr',
|
gridTemplateColumns: '1.2fr 1.4fr 1.4fr .9fr 1.3fr .9fr',
|
||||||
|
|||||||
21
app/src/hooks/useEscrow.ts
Normal file
21
app/src/hooks/useEscrow.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
18
app/src/hooks/useOrderByEscrow.ts
Normal file
18
app/src/hooks/useOrderByEscrow.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export * from './listing'
|
|||||||
// Order exports are imported but we exclude the re-exported deriveEscrowId to avoid duplication
|
// Order exports are imported but we exclude the re-exported deriveEscrowId to avoid duplication
|
||||||
// since it's already exported from pda.js
|
// since it's already exported from pda.js
|
||||||
export type { OrderAccountWithPda } from './order'
|
export type { OrderAccountWithPda } from './order'
|
||||||
export { fetchOrdersForListing, fetchOrdersByBuyer, fetchOrder } from './order'
|
export { fetchOrdersForListing, fetchOrdersByBuyer, fetchOrderByEscrow, fetchOrder } from './order'
|
||||||
|
|
||||||
// Re-export Address for consumers
|
// Re-export Address for consumers
|
||||||
export type { Address, Account } from '@solana/kit'
|
export type { Address, Account } from '@solana/kit'
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { address, type Account, type Address, type Rpc } from '@solana/kit'
|
import { address, type Account, type Address, type Rpc } from '@solana/kit'
|
||||||
import { type GetProgramAccountsApi } from '@solana/rpc-api'
|
import { type GetProgramAccountsApi } from '@solana/rpc-api'
|
||||||
import { type GetAccountInfoApi } 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 {
|
import {
|
||||||
decodeSolistingStateOrderAccount,
|
decodeSolistingStateOrderAccount,
|
||||||
fetchSolistingStateOrderAccount,
|
fetchSolistingStateOrderAccount,
|
||||||
SOLISTING_STATE_ORDER_ACCOUNT_DISCRIMINATOR,
|
getSolistingStateOrderAccountDiscriminatorBytes,
|
||||||
type SolistingStateOrderAccount,
|
type SolistingStateOrderAccount,
|
||||||
} from './generated/solisting/src/generated/index'
|
} from './generated/solisting/src/generated/index'
|
||||||
import { deriveEscrowId } from './pda'
|
import { deriveEscrowId } from './pda'
|
||||||
@@ -16,22 +16,57 @@ export type OrderAccountWithPda = Account<SolistingStateOrderAccount>
|
|||||||
type GpaRpc = Rpc<GetProgramAccountsApi>
|
type GpaRpc = Rpc<GetProgramAccountsApi>
|
||||||
type GetRpc = Rpc<GetAccountInfoApi>
|
type GetRpc = Rpc<GetAccountInfoApi>
|
||||||
|
|
||||||
async function fetchAllOrders(rpc: GpaRpc): Promise<OrderAccountWithPda[]> {
|
// OrderAccount byte layout (Sol-currency variant, the only one currently implemented):
|
||||||
const disc = SOLISTING_STATE_ORDER_ACCOUNT_DISCRIMINATOR
|
// 0: discriminator (8)
|
||||||
const discBase64 = Buffer.from(disc).toString('base64') as Base64EncodedBytes
|
// 8: listing (32)
|
||||||
|
// 40: buyer (32)
|
||||||
|
// 72: seller (32)
|
||||||
|
// 104: resolver (32)
|
||||||
|
// 136: paymentCurrency — Sol variant = 1 byte tag + 0 data = 1 byte total
|
||||||
|
// 137: amount (8)
|
||||||
|
// 145: 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 178.
|
||||||
|
const ESCROW_ACCOUNT_OFFSET = 145n
|
||||||
|
|
||||||
|
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
|
const results = await rpc
|
||||||
.getProgramAccounts(PROGRAM_ADDRESS, {
|
.getProgramAccounts(PROGRAM_ADDRESS, {
|
||||||
encoding: 'base64',
|
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()
|
.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'))
|
const data = new Uint8Array(Buffer.from(r.account.data[0], 'base64'))
|
||||||
return decodeSolistingStateOrderAccount({
|
return decodeSolistingStateOrderAccount({
|
||||||
address: r.pubkey,
|
address: r.pubkey,
|
||||||
data,
|
data,
|
||||||
executable: r.account.executable,
|
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,
|
programAddress: r.account.owner,
|
||||||
space: r.account.space,
|
space: r.account.space,
|
||||||
exists: true,
|
exists: true,
|
||||||
@@ -39,26 +74,20 @@ async function fetchAllOrders(rpc: GpaRpc): Promise<OrderAccountWithPda[]> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrdersForListing(
|
export function fetchOrdersForListing(rpc: GpaRpc, listingPk: Address): Promise<OrderAccountWithPda[]> {
|
||||||
rpc: GpaRpc,
|
return gpa(rpc, { offset: LISTING_OFFSET, addr: listingPk })
|
||||||
listingPk: Address,
|
|
||||||
): Promise<OrderAccountWithPda[]> {
|
|
||||||
const all = await fetchAllOrders(rpc)
|
|
||||||
return all.filter((o) => o.data.listing === listingPk)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrdersByBuyer(
|
export function fetchOrdersByBuyer(rpc: GpaRpc, buyer: Address): Promise<OrderAccountWithPda[]> {
|
||||||
rpc: GpaRpc,
|
return gpa(rpc, { offset: BUYER_OFFSET, addr: buyer })
|
||||||
buyer: Address,
|
|
||||||
): Promise<OrderAccountWithPda[]> {
|
|
||||||
const all = await fetchAllOrders(rpc)
|
|
||||||
return all.filter((o) => o.data.buyer === buyer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrder(
|
export async function fetchOrderByEscrow(rpc: GpaRpc, escrowPda: Address): Promise<OrderAccountWithPda | null> {
|
||||||
rpc: GetRpc,
|
const results = await gpa(rpc, { offset: ESCROW_ACCOUNT_OFFSET, addr: escrowPda })
|
||||||
addr: Address,
|
return results[0] ?? null
|
||||||
): Promise<OrderAccountWithPda | null> {
|
}
|
||||||
|
|
||||||
|
export async function fetchOrder(rpc: GetRpc, addr: Address): Promise<OrderAccountWithPda | null> {
|
||||||
return fetchSolistingStateOrderAccount(rpc, addr).catch(() => null)
|
return fetchSolistingStateOrderAccount(rpc, addr).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user