diff --git a/app/src/app/escrow/[pk]/page.tsx b/app/src/app/escrow/[pk]/page.tsx new file mode 100644 index 0000000..49c302c --- /dev/null +++ b/app/src/app/escrow/[pk]/page.tsx @@ -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 +} diff --git a/app/src/components/EscrowDetail.tsx b/app/src/components/EscrowDetail.tsx new file mode 100644 index 0000000..211d741 --- /dev/null +++ b/app/src/components/EscrowDetail.tsx @@ -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
Loading…
+ if (error || !escrow) return
Escrow account not found.
+ + 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 ( +
+ + +
+
+
+ ESCROW ACCOUNT · #{String(ed.escrowId)} +
+

{abbrev(pda)}

+
+ {stateLabel} +
+ + + +
+
+ ESCROW ACCOUNT · descro +
+ + + navigator.clipboard.writeText(pda)} /> + + + {stateLabel} + + + #{String(ed.escrowId)} + + + navigator.clipboard.writeText(ed.seller)} /> + + + navigator.clipboard.writeText(ed.buyer)} /> + + + {fmtSol(ed.amount)} + + {resolverPk && ( + + router.push(`/resolver/${resolverPk}`)} + onCopy={() => navigator.clipboard.writeText(resolverPk)} + /> + + )} + + {order + ? ( + router.push(`/order/${order.address}`)} + onCopy={() => navigator.clipboard.writeText(order.address)} + /> + ) + : + } + +
+
+ ) +} diff --git a/app/src/components/EscrowsTable.tsx b/app/src/components/EscrowsTable.tsx index 04264f7..fe1fbb6 100644 --- a/app/src/components/EscrowsTable.tsx +++ b/app/src/components/EscrowsTable.tsx @@ -137,7 +137,7 @@ export function EscrowsTable() { return (
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', diff --git a/app/src/hooks/useEscrow.ts b/app/src/hooks/useEscrow.ts new file mode 100644 index 0000000..1fe42df --- /dev/null +++ b/app/src/hooks/useEscrow.ts @@ -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[0], + pda, + ).catch(() => null) + }, + enabled: !!cluster && !!pda, + refetchInterval: 15_000, + }) +} diff --git a/app/src/hooks/useOrderByEscrow.ts b/app/src/hooks/useOrderByEscrow.ts new file mode 100644 index 0000000..3cb2739 --- /dev/null +++ b/app/src/hooks/useOrderByEscrow.ts @@ -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, + }) +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 67b32d8..c501c5d 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -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' diff --git a/sdk/src/order.ts b/sdk/src/order.ts index 03d0c14..4cf4fb9 100644 --- a/sdk/src/order.ts +++ b/sdk/src/order.ts @@ -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 type GpaRpc = Rpc type GetRpc = Rpc -async function fetchAllOrders(rpc: GpaRpc): Promise { - 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 (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 { 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 { }) } -export async function fetchOrdersForListing( - rpc: GpaRpc, - listingPk: Address, -): Promise { - const all = await fetchAllOrders(rpc) - return all.filter((o) => o.data.listing === listingPk) +export function fetchOrdersForListing(rpc: GpaRpc, listingPk: Address): Promise { + return gpa(rpc, { offset: LISTING_OFFSET, addr: listingPk }) } -export async function fetchOrdersByBuyer( - rpc: GpaRpc, - buyer: Address, -): Promise { - const all = await fetchAllOrders(rpc) - return all.filter((o) => o.data.buyer === buyer) +export function fetchOrdersByBuyer(rpc: GpaRpc, buyer: Address): Promise { + return gpa(rpc, { offset: BUYER_OFFSET, addr: buyer }) } -export async function fetchOrder( - rpc: GetRpc, - addr: Address, -): Promise { +export async function fetchOrderByEscrow(rpc: GpaRpc, escrowPda: Address): Promise { + const results = await gpa(rpc, { offset: ESCROW_ACCOUNT_OFFSET, addr: escrowPda }) + return results[0] ?? null +} + +export async function fetchOrder(rpc: GetRpc, addr: Address): Promise { return fetchSolistingStateOrderAccount(rpc, addr).catch(() => null) }