docs: add 4-part Solisting Explorer implementation plan

This commit is contained in:
thesn10
2026-06-24 20:34:13 +02:00
parent 7c91d2c52b
commit e326bfee51
4 changed files with 3587 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,837 @@
# Solisting App — Write Transactions + Dashboard (Part 4 of 4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement all write transactions (7 solisting instructions + 3 descro-side actions), the `useTx` helper hook, the Create/Update Listing form, the Dashboard, and wire action handlers into `ListingDetail` and `OrderDetail`.
**Architecture:** `useTx` encapsulates the `@solana/kit` transaction pipeline (build → sign → confirm) using `useKitTransactionSigner` + `useSolanaClient` from `@solana/connector/react`. After confirmation it calls `useToast` and `queryClient.invalidateQueries`. All instruction builders come from `@solisting/sdk` (codama-generated). Dashboard pages filter React Query cache by the connected wallet address.
**Prerequisites:** Parts 13 complete. The codama-generated instruction builders are available from `@solisting/sdk`. `SOLISTING_PROGRAM_ADDRESS` is exported from the SDK.
---
## File Map
| Path | Purpose |
|---|---|
| `app/src/hooks/useTx.ts` | Generic send-tx + toast + cache invalidation |
| `app/src/hooks/useMyListings.ts` | User's listings (filters useListings cache) |
| `app/src/hooks/useMyOrders.ts` | User's orders as buyer |
| `app/src/components/CreateListingForm.tsx` | Create + update listing form |
| `app/src/components/Dashboard.tsx` | My Listings + My Orders tabs |
| `app/src/app/listing/create/page.tsx` | `/listing/create?edit=<pk>` |
| `app/src/app/dashboard/page.tsx` | `/dashboard` |
**Modified files (adding write handlers):**
| Path | Change |
|---|---|
| `app/src/app/listing/[pk]/page.tsx` | Wire `onUpdate`, `onClose`, `onPlaceOrder` |
| `app/src/app/order/[pk]/page.tsx` | Wire all order action handlers |
---
### Task 19: `useTx` Hook
**Files:** `app/src/hooks/useTx.ts`
- [ ] **Step 1: Create `app/src/hooks/useTx.ts`**
```ts
import {
pipe,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
signTransactionMessageWithSigners,
sendAndConfirmTransactionFactory,
assertIsTransactionWithBlockhashLifetime,
} from '@solana/kit'
import { useKitTransactionSigner, useSolanaClient } from '@solana/connector/react'
import { useQueryClient } from '@tanstack/react-query'
import { useToast } from '@/components/ui/Toast'
type AnyInstruction = Parameters<typeof appendTransactionMessageInstructions>[0][number]
export function useTx() {
const { signer } = useKitTransactionSigner()
const { client } = useSolanaClient()
const queryClient = useQueryClient()
const toast = useToast()
return async (
instructions: AnyInstruction[],
invalidateKeys: string[][],
label: string,
): Promise<void> => {
if (!signer || !client) throw new Error('Wallet not connected')
const { rpc, rpcSubscriptions } = client
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send()
const txMsg = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(signer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions(instructions, tx),
)
const signed = await signTransactionMessageWithSigners(txMsg)
assertIsTransactionWithBlockhashLifetime(signed)
await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions: rpcSubscriptions as never })(
signed,
{ commitment: 'confirmed' },
)
toast(`Tx confirmed — ${label}`)
for (const key of invalidateKeys) {
queryClient.invalidateQueries({ queryKey: key })
}
}
}
```
- [ ] **Step 2: Type-check**
```bash
cd app && yarn typecheck
```
- [ ] **Step 3: Commit**
```bash
git add app/src/hooks/useTx.ts
git commit -m "feat: useTx hook for on-chain transactions"
```
---
### Task 20: Listing Write Instructions
Wire `create_listing`, `update_listing`, and `close_listing` into a page-level component. The form handles creation and update via the `?edit=<pk>` query param.
**Files:** `app/src/components/CreateListingForm.tsx`, `app/src/app/listing/create/page.tsx`
- [ ] **Step 1: Create `app/src/components/CreateListingForm.tsx`**
```tsx
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useKitTransactionSigner, useWallet } from '@solana/connector/react'
import { useTx } from '@/hooks/useTx'
import { useListing } from '@/hooks/useListing'
import { Button } from '@/components/ui/Button'
import { useToast } from '@/components/ui/Toast'
import {
getCreateListingInstructionAsync,
getUpdateListingInstruction,
SOLISTING_PROGRAM_ADDRESS,
findListingPda,
} from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
// getCreateListingInstructionAsync / getUpdateListingInstruction names depend on
// codama output — inspect sdk/src/generated/solisting/instructions/ and adjust.
interface FormState {
price: string
quantity: string
oracle: string
metadataUri: string
}
function blank(): FormState {
return { price: '', quantity: '', oracle: '', metadataUri: '' }
}
function SOL_TO_LAMPORTS(sol: string): bigint {
const n = parseFloat(sol)
if (isNaN(n) || n <= 0) throw new Error('Invalid price')
return BigInt(Math.round(n * 1e9))
}
interface Props {
editPk?: Address
}
export function CreateListingForm({ editPk }: Props) {
const router = useRouter()
const { signer } = useKitTransactionSigner()
const { account } = useWallet()
const sendTx = useTx()
const toast = useToast()
const { data: existing } = useListing(editPk ?? ('' as Address))
const [form, setForm] = useState<FormState>(blank())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isUpdate = !!editPk
// Pre-fill from existing listing data on first render
const initialized = useState(false)
if (!initialized[0] && existing && isUpdate) {
initialized[1](true)
const d = existing.data
setForm({
price: (Number(d.price) / 1e9).toString(), // assumes SOL canonical
quantity: String(d.quantity),
oracle: d.canonicalOracle ?? '',
metadataUri: d.metadataUri ?? '',
})
}
function set(key: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [key]: e.target.value }))
}
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!signer || !account) { setError('Connect your wallet first.'); return }
setError(null)
setLoading(true)
try {
if (isUpdate && editPk) {
// update_listing
const ix = getUpdateListingInstruction({
listing: editPk,
seller: signer,
// Pass only changed fields — inspect the generated instruction signature
// and include all required params here:
price: SOL_TO_LAMPORTS(form.price),
quantity: BigInt(form.quantity),
canonicalOracle: form.oracle ? form.oracle as Address : null,
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings'], ['listing', editPk]], 'update_listing')
} else {
// create_listing — listingId is auto-assigned (read from a counter or
// use a client-side incrementing ID stored in localStorage)
const listingId = BigInt(Date.now()) // simple unique ID; program validates uniqueness via PDA
const [listingPda] = await findListingPda(account as Address, listingId)
const ix = await getCreateListingInstructionAsync({
listing: listingPda,
seller: signer,
listingId,
canonicalCurrency: { __kind: 'Sol' } as never,
price: SOL_TO_LAMPORTS(form.price),
quantity: BigInt(form.quantity),
canonicalOracle: form.oracle ? form.oracle as Address : null,
altCurrencies: [],
acceptedResolvers: [],
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings']], 'create_listing')
router.push('/listings')
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
const LABEL_STYLE = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--mut)', marginBottom: 8, letterSpacing: '.04em' } as const
const INPUT_STYLE = { width: '100%', height: 42, padding: '0 13px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, fontSize: 13, outline: 'none' } as const
return (
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<button onClick={() => router.back()} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}>
Cancel
</button>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
{isUpdate ? 'Update Listing' : 'Create Listing'}
</h1>
<p style={{ margin: '6px 0 26px', fontSize: 13, color: 'var(--mut)' }}>
{isUpdate ? `Editing listing ${editPk?.slice(0, 8)}` : 'New SOL-priced listing on the solisting program'}
</p>
<form onSubmit={submit} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '26px 28px', display: 'flex', flexDirection: 'column', gap: 22 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<label style={LABEL_STYLE}>PRICE (SOL)</label>
<input value={form.price} onChange={set('price')} placeholder="0.00" style={INPUT_STYLE} />
</div>
<div>
<label style={LABEL_STYLE}>QUANTITY</label>
<input value={form.quantity} onChange={set('quantity')} placeholder="0" style={INPUT_STYLE} />
</div>
</div>
<div>
<label style={LABEL_STYLE}>CANONICAL ORACLE <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional · Pyth V2 feed)</span></label>
<input value={form.oracle} onChange={set('oracle')} placeholder="Pyth price feed pubkey — leave empty for $1.00 stablecoin" style={INPUT_STYLE} />
</div>
<div>
<label style={LABEL_STYLE}>METADATA URI <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span></label>
<input value={form.metadataUri} onChange={set('metadataUri')} placeholder="ipfs://… or https://…" style={INPUT_STYLE} />
</div>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, padding: '10px 12px', background: 'var(--dangerSoft)', borderRadius: 8 }}>{error}</div>}
<div style={{ display: 'flex', gap: 10, borderTop: '1px solid var(--bdSoft)', paddingTop: 20 }}>
<Button type="submit" disabled={loading} style={{ flex: 1, padding: 13, fontSize: 14 }}>
{loading ? 'Sending…' : isUpdate ? 'Update Listing' : 'Create Listing'}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()} style={{ padding: '13px 22px', fontSize: 14 }}>
Cancel
</Button>
</div>
</form>
</div>
)
}
```
**Important:** After running codama (Task 3 of Part 1), open `sdk/src/generated/solisting/instructions/createListing.ts` and `updateListing.ts` to verify the exact parameter names. Common differences: `listingId` vs `listing_id`, `canonicalCurrency` vs `canonical_currency`, async vs sync variant. Adjust the code above accordingly.
- [ ] **Step 2: Create `app/src/app/listing/create/page.tsx`**
```tsx
'use client'
import { Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { CreateListingForm } from '@/components/CreateListingForm'
import type { Address } from '@solisting/sdk'
function CreateOrEdit() {
const searchParams = useSearchParams()
const editPk = searchParams.get('edit') as Address | null
return <CreateListingForm editPk={editPk ?? undefined} />
}
export default function CreateListingPage() {
return <Suspense><CreateOrEdit /></Suspense>
}
```
- [ ] **Step 3: Wire create/update/close handlers in `app/src/app/listing/[pk]/page.tsx`**
Replace the existing `listing/[pk]/page.tsx` with:
```tsx
'use client'
import { use } from 'react'
import { useRouter } from 'next/navigation'
import { useWallet } from '@solana/connector/react'
import { useKitTransactionSigner } from '@solana/connector/react'
import { ListingDetail } from '@/components/ListingDetail'
import { useTx } from '@/hooks/useTx'
import { getCloseListingInstruction } from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
export default function ListingPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const router = useRouter()
const { account } = useWallet()
const { signer } = useKitTransactionSigner()
const sendTx = useTx()
async function handleClose() {
if (!signer) return
const ix = getCloseListingInstruction({ listing: pk as Address, seller: signer })
await sendTx([ix as never], [['listings'], ['listing', pk]], 'close_listing')
router.push('/listings')
}
return (
<ListingDetail
pk={pk as Address}
walletAddress={account ?? null}
onUpdate={() => router.push(`/listing/create?edit=${pk}`)}
onClose={handleClose}
onPlaceOrder={() => router.push(`/listing/create?order=${pk}`)} // order form TBD — see note
/>
)
}
```
Note: `create_order` requires selecting payment currency and calling the oracle if alt currencies are configured. For simplicity, wire `onPlaceOrder` to a dedicated `/order/create?listing=<pk>` page if needed, or implement inline in `ListingDetail`. The `create_order` instruction is built in Task 21.
- [ ] **Step 4: Type-check + commit**
```bash
cd app && yarn typecheck
git add app/src/components/CreateListingForm.tsx app/src/app/listing/create/ app/src/app/listing/
git commit -m "feat: create/update/close listing write transactions"
```
---
### Task 21: Order Write Instructions
Wire `accept_order`, `reject_order`, `cancel_order`, `close_stale_order` (solisting) and `complete`, `dispute` (descro) into `OrderDetail`.
**Files:** Modified `app/src/app/order/[pk]/page.tsx`
- [ ] **Step 1: Read the codama-generated order instructions**
```bash
cat sdk/src/generated/solisting/instructions/acceptOrder.ts
cat sdk/src/generated/solisting/instructions/rejectOrder.ts
cat sdk/src/generated/solisting/instructions/cancelOrder.ts
cat sdk/src/generated/solisting/instructions/closeStaleOrder.ts
```
Note exact parameter names for each instruction. Required accounts typically include: `order`, `listing`, `seller` or `buyer` (as signer), `escrowAccount`, `descroProgram`.
Also check `@descro/sdk` for `getCompleteInstruction`, `getDisputeInstruction`, `getResolveInstruction`.
- [ ] **Step 2: Replace `app/src/app/order/[pk]/page.tsx`**
```tsx
'use client'
import { use } from 'react'
import { useWallet, useKitTransactionSigner } from '@solana/connector/react'
import { OrderDetail } from '@/components/OrderDetail'
import { useTx } from '@/hooks/useTx'
import { useOrder } from '@/hooks/useOrder'
import {
getAcceptOrderInstruction,
getRejectOrderInstruction,
getCancelOrderInstruction,
getCloseStaleOrderInstruction,
} from '@solisting/sdk'
import { getCompleteInstruction, getDisputeInstruction } from '@descro/sdk'
import type { Address } from '@solisting/sdk'
export default function OrderPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const { account } = useWallet()
const { signer } = useKitTransactionSigner()
const sendTx = useTx()
const { data } = useOrder(pk as Address)
const invalidate = [['order', pk], ['listings']]
async function handleAccept() {
if (!signer || !data) return
const od = data.order.data
const ix = getAcceptOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
seller: signer,
escrowAccount: od.escrowAccount,
// descroProgram: DESCRO_PROGRAM_ADDRESS — if required by the instruction
})
await sendTx([ix as never], invalidate, 'accept_order')
}
async function handleReject() {
if (!signer || !data) return
const od = data.order.data
const ix = getRejectOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
seller: signer,
escrowAccount: od.escrowAccount,
})
await sendTx([ix as never], invalidate, 'reject_order')
}
async function handleCancel() {
if (!signer || !data) return
const od = data.order.data
const ix = getCancelOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
buyer: signer,
escrowAccount: od.escrowAccount,
})
await sendTx([ix as never], invalidate, 'cancel_order')
}
async function handleCloseStale() {
if (!signer || !data) return
const od = data.order.data
const ix = getCloseStaleOrderInstruction({
order: pk as Address,
listing: od.listingAccount,
payer: signer,
escrowAccount: od.escrowAccount,
})
await sendTx([ix as never], invalidate, 'close_stale_order')
}
async function handleComplete() {
if (!signer || !data) return
const od = data.order.data
const ix = getCompleteInstruction({
escrow: od.escrowAccount,
buyer: signer,
})
await sendTx([ix as never], [['order', pk]], 'complete')
}
async function handleDispute() {
if (!signer || !data) return
const od = data.order.data
const ix = getDisputeInstruction({
escrow: od.escrowAccount,
raiser: signer,
})
await sendTx([ix as never], [['order', pk]], 'dispute')
}
return (
<OrderDetail
pk={pk as Address}
walletAddress={account ?? null}
onAccept={handleAccept}
onReject={handleReject}
onCancel={handleCancel}
onCloseStale={handleCloseStale}
onComplete={handleComplete}
onDispute={handleDispute}
/>
)
}
```
**Important:** The exact parameter names and required accounts for each instruction come from codama output. After reading the generated files in Step 1, adjust every instruction call to match. Common required accounts: `listing`, `order`, `escrowAccount`, `descroProgram` (as a `programId` or regular address arg), plus the signer (`seller`, `buyer`, or `payer`).
- [ ] **Step 3: Type-check**
```bash
cd app && yarn typecheck
```
Fix any instruction parameter mismatches by re-reading the codama-generated instruction files.
- [ ] **Step 4: Commit**
```bash
git add app/src/app/order/
git commit -m "feat: order write instructions (accept/reject/cancel/close-stale/complete/dispute)"
```
---
### Task 22: `create_order` Instruction
The `create_order` instruction requires:
- The buyer's chosen payment currency (SOL or an alt currency from `listing.altCurrencies`)
- The `oracle` account keys from the listing (passed verbatim: `listing.canonicalOracle` / `alt.usdOracle`)
- The derived `escrowId` (`deriveEscrowId(orderPda)`)
- An `expectedAmount` + `maxSlippageBps` for oracle slippage check (pass 0 for no slippage check)
For now, only SOL payment (canonical currency = Sol, no oracle) is fully supported in the UI, since SPL is rejected by the program with `SplNotImplemented`.
- [ ] **Step 1: Add place-order flow to `listing/[pk]/page.tsx`**
Replace the `onPlaceOrder` stub with an inline SOL order:
```tsx
// Add to imports in listing/[pk]/page.tsx:
import {
getCreateOrderInstructionAsync,
findOrderPda,
deriveEscrowId,
} from '@solisting/sdk'
import { DESCRO_PROGRAM_ADDRESS } from '@descro/sdk'
import { useListing } from '@/hooks/useListing'
// Inside the component (add these):
const { data: listing } = useListing(pk as Address)
const [orderLoading, setOrderLoading] = useState(false)
async function handlePlaceOrder() {
if (!signer || !account || !listing) return
setOrderLoading(true)
try {
const orderId = BigInt(Date.now()) // unique per buyer; program validates via PDA
const [orderPda] = await findOrderPda(pk as Address, account as Address, orderId)
const escrowId = deriveEscrowId(orderPda)
const ix = await getCreateOrderInstructionAsync({
order: orderPda,
listing: pk as Address,
buyer: signer,
// canonicalOracle: listing.data.canonicalOracle ?? null,
paymentCurrency: { __kind: 'Sol' } as never,
orderId,
escrowId,
expectedAmount: listing.data.price,
maxSlippageBps: 0,
descroProgram: DESCRO_PROGRAM_ADDRESS,
// additional accounts from codama (escrowAccount PDA, vault, etc.)
})
await sendTx([ix as never], [['listing', pk], ['orders', pk]], 'create_order')
} catch (err) {
// toast is called by sendTx on success; show error here
console.error(err)
} finally {
setOrderLoading(false)
}
}
```
Then pass `onPlaceOrder={handlePlaceOrder}` to `<ListingDetail>`.
**Important:** `getCreateOrderInstructionAsync` may require additional accounts like the `escrowAccount` PDA (from descro), the `vault` PDA, and the descro program itself. After reviewing the codama-generated `createOrder.ts`, pass all required accounts. The descro SDK exports helpers for finding these PDAs.
- [ ] **Step 2: Type-check**
```bash
cd app && yarn typecheck
```
- [ ] **Step 3: Commit**
```bash
git add app/src/app/listing/
git commit -m "feat: create_order instruction (SOL canonical currency)"
```
---
### Task 23: Dashboard
**Files:** `app/src/hooks/useMyListings.ts`, `app/src/hooks/useMyOrders.ts`, `app/src/components/Dashboard.tsx`, `app/src/app/dashboard/page.tsx`
- [ ] **Step 1: Create `app/src/hooks/useMyListings.ts`**
```ts
import { useWallet } from '@solana/connector/react'
import { useListings } from './useListings'
export function useMyListings() {
const { account } = useWallet()
const { data: all = [], ...rest } = useListings()
return { data: all.filter((l) => l.data.seller === account), ...rest }
}
```
- [ ] **Step 2: Create `app/src/hooks/useMyOrders.ts`**
```ts
import { useQuery } from '@tanstack/react-query'
import { useCluster, useWallet } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchOrdersByBuyer } from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
export function useMyOrders() {
const { account } = useWallet()
const { cluster } = useCluster()
return useQuery({
queryKey: ['myOrders', account, cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchOrdersByBuyer(rpc, account as Address)
},
enabled: !!account && !!cluster,
refetchInterval: 30_000,
})
}
```
- [ ] **Step 3: Create `app/src/components/Dashboard.tsx`**
```tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useWallet } from '@solana/connector/react'
import { useMyListings } from '@/hooks/useMyListings'
import { useMyOrders } from '@/hooks/useMyOrders'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { abbrev, fmtSol } from '@/lib/format'
export function Dashboard() {
const router = useRouter()
const { account } = useWallet()
const [tab, setTab] = useState<'listings' | 'orders'>('listings')
const { data: myListings = [], isLoading: loadingL } = useMyListings()
const { data: myOrders = [], isLoading: loadingO } = useMyOrders()
if (!account) {
return (
<div style={{ maxWidth: 420, margin: '80px auto', textAlign: 'center', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '40px 32px' }}>
<div style={{ width: 48, height: 48, borderRadius: 14, background: 'var(--accSoft)', border: '1px solid var(--accBd)', margin: '0 auto 18px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 16, height: 16, borderRadius: 5, background: 'linear-gradient(135deg, var(--acc), var(--acc2))' }} />
</div>
<h2 style={{ margin: '0 0 8px', fontSize: 20, fontWeight: 700 }}>Wallet required</h2>
<p style={{ margin: '0 0 22px', fontSize: 13.5, color: 'var(--mut)', lineHeight: 1.55 }}>
Connect a wallet to view your listings and orders, and to execute instructions you're authorized for.
</p>
</div>
)
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16, marginBottom: 22, flexWrap: 'wrap' }}>
<div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>My Dashboard</h1>
<p style={{ margin: '6px 0 0', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--mut)' }}>{abbrev(account)}</p>
</div>
<Button onClick={() => router.push('/listing/create')}>+ Create Listing</Button>
</div>
<div style={{ display: 'flex', gap: 4, background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 11, padding: 4, width: 'max-content', marginBottom: 20 }}>
{[
{ key: 'listings' as const, label: `My Listings · ${myListings.length}` },
{ key: 'orders' as const, label: `My Orders · ${myOrders.length}` },
].map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
style={{ padding: '9px 18px', borderRadius: 8, fontSize: 13, fontWeight: 600, color: tab === t.key ? 'var(--tx)' : 'var(--mut)', background: tab === t.key ? 'var(--bg3)' : 'transparent' }}
>
{t.label}
</button>
))}
</div>
{tab === 'listings' && (
<div>
{loadingL && <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>}
{!loadingL && myListings.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)' }}>
You have no listings yet.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{myListings.map((l) => {
const sc = STATUS_COLORS[l.data.isActive ? 'active' : 'inactive']
const avail = Number(l.data.quantity) - Number(l.data.quantityReserved)
return (
<div key={l.address} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '18px 20px', display: 'flex', alignItems: 'center', gap: 18 }}>
<div onClick={() => router.push(`/listing/${l.address}`)} style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontWeight: 600, fontSize: 15 }}>{l.data.metadataUri || abbrev(l.address)}</span>
<Badge color={sc.color} bg={sc.bg}>{l.data.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
<div style={{ fontSize: 12, color: 'var(--mut)', marginTop: 4, fontFamily: 'var(--font-mono)' }}>
#{String(l.data.listingId)} · {fmtSol(l.data.price)} · {avail}/{String(l.data.quantity)} avail · {String(l.data.quantityReserved)} pending
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<Button variant="ghost" style={{ padding: '8px 14px', fontSize: 12.5 }} onClick={() => router.push(`/listing/create?edit=${l.address}`)}>Update</Button>
<Button variant="danger" style={{ padding: '8px 14px', fontSize: 12.5 }} onClick={() => router.push(`/listing/${l.address}`)}>Close</Button>
</div>
</div>
)
})}
</div>
</div>
)}
{tab === 'orders' && (
<div>
{loadingO && <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading…</div>}
{!loadingO && myOrders.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)', background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)' }}>
You have no orders as a buyer.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{myOrders.map((o) => (
<div
key={o.address}
onClick={() => router.push(`/order/${o.address}`)}
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '18px 20px', display: 'flex', alignItems: 'center', gap: 18, cursor: 'pointer' }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>{abbrev(o.data.listingAccount)}</div>
<div style={{ fontSize: 12, color: 'var(--mut)', marginTop: 4, fontFamily: 'var(--font-mono)' }}>
#{String(o.data.orderId)} · {fmtSol(o.data.amount)}
</div>
</div>
<span style={{ fontSize: 11.5, fontWeight: 600, padding: '5px 13px', borderRadius: 999, background: 'var(--bg3)', color: 'var(--mut)', flexShrink: 0 }}>
#{String(o.data.escrowId)}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
```
- [ ] **Step 4: Create `app/src/app/dashboard/page.tsx`**
```tsx
import { Dashboard } from '@/components/Dashboard'
export default function DashboardPage() { return <Dashboard /> }
```
- [ ] **Step 5: Type-check + commit**
```bash
cd app && yarn typecheck
git add app/src/hooks/useMyListings.ts app/src/hooks/useMyOrders.ts app/src/components/Dashboard.tsx app/src/app/dashboard/
git commit -m "feat: dashboard with my listings and my orders"
```
---
### Task 24: Final Wiring + Smoke Test
- [ ] **Step 1: Full type-check**
```bash
cd app && yarn typecheck
```
Expected: zero errors. If there are errors due to codama-generated type shapes (e.g. `__kind` vs a different discriminant, or field names that differ), fix them by re-inspecting the generated files in `sdk/src/generated/`.
- [ ] **Step 2: Build**
```bash
cd app && yarn build
```
Expected: successful Next.js production build. Fix any remaining type or import errors.
- [ ] **Step 3: Run dev server + smoke test each route**
```bash
cd app && yarn dev
```
Visit and verify each route renders without crashing:
- `http://localhost:3000/` → redirects to `/listings`
- `http://localhost:3000/listings` → table (empty or with data from devnet)
- `http://localhost:3000/escrows` → escrow table
- `http://localhost:3000/resolvers` → resolver grid
- `http://localhost:3000/dashboard` → wallet-required prompt (if disconnected)
- `http://localhost:3000/listing/create` → form renders
- `http://localhost:3000/search?q=test` → "No account found" page
Connect a wallet on devnet and verify:
- Dashboard shows wallet address and tabs
- "Create Listing" button navigates to the form
- Form submits (requires SOL on devnet — use faucet at `https://faucet.solana.com`)
- [ ] **Step 4: Final commit**
```bash
git add -A
git commit -m "feat: complete Solisting Explorer app"
```
---
## Post-Implementation Notes
**Codama field name adjustments:** Throughout this plan, field names like `__kind`, `canonicalCurrency`, `quantityReserved`, `escrowAccount`, `ruledForBuyer` are assumed based on Rust → camelCase conventions. After running codama (Part 1 Task 3), always verify against the generated files before trusting the code above.
**`od.seller` in OrderDetail:** The `OrderAccount` may not store a seller field directly — it stores `listingAccount`. Fetch the seller from the ListingAccount or from the descro EscrowAccount (`ed.seller`) for display and role detection.
**SPL currency:** `Currency::Spl` is rejected by the program (`SplNotImplemented`). The form only supports SOL as canonical currency for now. The alt-currencies UI is read-only (displays configured alts but doesn't build a create_order tx with SPL payment).
**`listingId` for create_listing:** `BigInt(Date.now())` gives a millisecond-precision unique ID. The program validates uniqueness only via the PDA seeds, so collisions within the same millisecond for the same seller would fail on-chain. This is fine for a UI but production apps should track the next ID more carefully.
**DESCRO_PROGRAM_ADDRESS:** Exported from `@descro/sdk`. Verify with `grep -r DESCRO_PROGRAM_ADDRESS descro/sdk/src/generated/`. Pass it to any solisting instruction that CPIs into descro (notably `create_order`, `accept_order`, `reject_order`, `cancel_order`).

View File

@@ -0,0 +1,488 @@
# Solisting SDK Implementation Plan (Part 1 of 4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Generate the Anchor IDL, run Codama to produce TypeScript bindings, and write hand-crafted SDK helpers for the `@solisting/sdk` yarn workspace package.
**Architecture:** The SDK mirrors the `@descro/sdk` pattern: Codama renders the IDL into typed account decoders and instruction builders under `src/generated/`, while hand-written files in `src/` add PDA helpers, filtering utilities, and the critical `deriveEscrowId` function. `@descro/sdk` is referenced as a local `file:` path (not npm).
**Tech Stack:** Anchor IDL build, `codama` + `@codama/renderers-js`, `@solana/kit ^6`, `@solana/program-client-core`, vitest for unit tests.
---
## File Map
| Path | Purpose |
|---|---|
| `package.json` (root) | Add `"workspaces": ["sdk","app"]` |
| `sdk/package.json` | Workspace package `@solisting/sdk` |
| `sdk/tsconfig.json` | TypeScript config |
| `sdk/codama.solisting.json` | Codama render config |
| `sdk/src/idl/solisting.json` | Anchor-generated IDL (copied from `target/idl/`) |
| `sdk/src/generated/solisting/` | Codama output — do NOT edit manually |
| `sdk/src/pda.ts` | `findListingPda`, `findOrderPda`, `deriveEscrowId` |
| `sdk/src/listing.ts` | `fetchAllListings`, `fetchListingsBySeller` |
| `sdk/src/order.ts` | `fetchOrdersForListing`, `fetchOrdersByBuyer` |
| `sdk/src/index.ts` | Public re-exports |
| `sdk/src/__tests__/pda.test.ts` | Unit tests |
---
### Task 1: Generate the Anchor IDL
**Files:**
- Read: `programs/solisting/Cargo.toml` (confirm `idl-build` feature exists)
- Output: `target/idl/solisting.json`
- [ ] **Step 1: Verify anchor-cli is installed**
```bash
anchor --version
```
Expected: `anchor-cli 0.30.x` or `1.x`
- [ ] **Step 2: Generate the IDL (host-target compilation, not SBF)**
```bash
anchor idl build --program-name solisting
```
If this fails, use the full build (slower):
```bash
anchor build
```
Expected: `target/idl/solisting.json` created
- [ ] **Step 3: Confirm IDL structure**
```bash
cat target/idl/solisting.json | python3 -m json.tool | head -60
```
Expected: JSON with `"name": "solisting"`, `"accounts"`, `"instructions"`, `"types"` keys. Confirm accounts named `listingAccount` and `orderAccount`, and 8 instructions.
- [ ] **Step 4: Commit**
```bash
git add target/idl/solisting.json
git commit -m "chore: generate solisting IDL"
```
---
### Task 2: SDK Package Setup
**Files:**
- Modify: `package.json` (root)
- Create: `sdk/package.json`
- Create: `sdk/tsconfig.json`
- Create: `sdk/codama.solisting.json`
- Create: `sdk/src/idl/solisting.json`
- [ ] **Step 1: Update root package.json to add workspaces**
Replace `package.json` at repo root with:
```json
{
"name": "solisting",
"license": "ISC",
"private": true,
"workspaces": ["sdk", "app"],
"scripts": {
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
},
"devDependencies": {
"prettier": "^3.8.3"
}
}
```
- [ ] **Step 2: Create `sdk/package.json`**
```json
{
"name": "@solisting/sdk",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"generate": "codama run js --config codama.solisting.json",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@descro/sdk": "file:../../descro/sdk",
"@solana/kit": "^6.0.0",
"@solana/program-client-core": "^6.4.0"
},
"devDependencies": {
"@codama/nodes-from-anchor": "^1.4.1",
"@codama/renderers-js": "^2.2.0",
"codama": "^1.6.0",
"typescript": "^6.0.3",
"vitest": "^3.0.0"
}
}
```
- [ ] **Step 3: Create `sdk/tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
- [ ] **Step 4: Create `sdk/codama.solisting.json`**
```json
{
"idl": "./src/idl/solisting.json",
"scripts": {
"js": [
{
"from": "@codama/renderers-js",
"args": ["./src/generated/solisting"]
}
]
}
}
```
- [ ] **Step 5: Copy the IDL into the SDK**
```bash
mkdir -p sdk/src/idl
cp target/idl/solisting.json sdk/src/idl/solisting.json
```
- [ ] **Step 6: Install dependencies**
```bash
yarn install
```
Expected: `sdk/node_modules/` populated, `@codama/renderers-js` available.
- [ ] **Step 7: Commit**
```bash
git add sdk/package.json sdk/tsconfig.json sdk/codama.solisting.json sdk/src/idl/solisting.json package.json
git commit -m "feat: add @solisting/sdk package scaffold"
```
---
### Task 3: Run Codama
**Files:**
- Create: `sdk/src/generated/solisting/` (entire directory, auto-generated)
- [ ] **Step 1: Run codama**
```bash
cd sdk && yarn generate
```
Expected: `sdk/src/generated/solisting/` created with subdirectories:
```
src/generated/solisting/
├── accounts/
│ ├── index.ts
│ ├── listingAccount.ts
│ └── orderAccount.ts
├── instructions/
│ ├── index.ts
│ ├── createListing.ts
│ ├── updateListing.ts
│ ├── closeListing.ts
│ ├── createOrder.ts
│ ├── acceptOrder.ts
│ ├── rejectOrder.ts
│ ├── cancelOrder.ts
│ └── closeStaleOrder.ts
├── pdas/
│ ├── index.ts
│ ├── listingAccount.ts
│ └── orderAccount.ts
├── types/
│ ├── index.ts
│ ├── currency.ts
│ └── altCurrencyConfig.ts
├── errors/
│ └── solisting.ts
├── programs/
│ └── solisting.ts
└── index.ts
```
- [ ] **Step 2: Inspect the generated account types**
```bash
cat sdk/src/generated/solisting/accounts/listingAccount.ts | head -40
```
Note the exact field names (camelCase in TS, e.g. `quantityReserved`, `isActive`, `canonicalCurrency`, `metadataUri`). These must match what you use in the hand-written helpers and components.
- [ ] **Step 3: Inspect the generated PDA functions**
```bash
cat sdk/src/generated/solisting/pdas/listingAccount.ts
cat sdk/src/generated/solisting/pdas/orderAccount.ts
```
Note the exact seed parameter names (e.g. `{ seller, listingId }` or `{ seller, listingIdLeBytes }`). Required in Task 4.
- [ ] **Step 4: Inspect the generated instruction builders**
```bash
cat sdk/src/generated/solisting/instructions/createListing.ts | head -50
```
Note all required and optional account + data fields. These will be used in Plan 4 (write transactions).
- [ ] **Step 5: Add generated directory to git**
```bash
git add sdk/src/generated/
git commit -m "feat: generate solisting codama bindings"
```
---
### Task 4: SDK Hand-Written Helpers
**Files:**
- Create: `sdk/src/pda.ts`
- Create: `sdk/src/listing.ts`
- Create: `sdk/src/order.ts`
- Create: `sdk/src/__tests__/pda.test.ts`
- [ ] **Step 1: Write `sdk/src/pda.ts`**
```ts
import { getAddressEncoder, type Address, type ProgramDerivedAddress } from '@solana/kit'
import { findListingAccountPda, findOrderAccountPda } from './generated/solisting'
// Convenience wrappers so callers don't need to pass the program address.
export async function findListingPda(
seller: Address,
listingId: bigint,
): Promise<ProgramDerivedAddress> {
return findListingAccountPda({ seller, listingId })
}
export async function findOrderPda(
listingAccount: Address,
buyer: Address,
orderId: bigint,
): Promise<ProgramDerivedAddress> {
return findOrderAccountPda({ listingAccount, buyer, orderId })
}
/**
* Replicates the program's escrow_id derivation:
* u64::from_le_bytes(order_pda.to_bytes()[0..8])
* Must match exactly — used when building create_order instructions.
*/
export function deriveEscrowId(orderPda: Address): bigint {
const bytes = getAddressEncoder().encode(orderPda) // 32-byte Uint8Array
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return view.getBigUint64(0, true) // little-endian, first 8 bytes
}
```
Note: If codama's `findListingAccountPda` seed parameters differ (inspect in Task 3 Step 3), adjust accordingly. The seeds from CLAUDE.md are `[b"listing", seller, listing_id_le]`.
- [ ] **Step 2: Write `sdk/src/listing.ts`**
```ts
import type { Account, Address } from '@solana/kit'
import {
fetchAllListingAccounts,
fetchListingAccount,
type ListingAccount,
} from './generated/solisting'
export type ListingAccountWithPda = Account<ListingAccount>
type Rpc = Parameters<typeof fetchAllListingAccounts>[0]
export async function fetchAllListings(rpc: Rpc): Promise<ListingAccountWithPda[]> {
return fetchAllListingAccounts(rpc)
}
export async function fetchListingsBySeller(
rpc: Rpc,
seller: Address,
): Promise<ListingAccountWithPda[]> {
const all = await fetchAllListingAccounts(rpc)
return all.filter((l) => l.data.seller === seller)
}
export async function fetchListing(
rpc: Rpc,
address: Address,
): Promise<ListingAccountWithPda | null> {
return fetchListingAccount(rpc, address).catch(() => null)
}
```
- [ ] **Step 3: Write `sdk/src/order.ts`**
```ts
import type { Account, Address } from '@solana/kit'
import {
fetchAllOrderAccounts,
fetchOrderAccount,
type OrderAccount,
} from './generated/solisting'
import { deriveEscrowId } from './pda'
export type OrderAccountWithPda = Account<OrderAccount>
type Rpc = Parameters<typeof fetchAllOrderAccounts>[0]
export async function fetchOrdersForListing(
rpc: Rpc,
listingPk: Address,
): Promise<OrderAccountWithPda[]> {
const all = await fetchAllOrderAccounts(rpc)
return all.filter((o) => o.data.listingAccount === listingPk)
}
export async function fetchOrdersByBuyer(
rpc: Rpc,
buyer: Address,
): Promise<OrderAccountWithPda[]> {
const all = await fetchAllOrderAccounts(rpc)
return all.filter((o) => o.data.buyer === buyer)
}
export async function fetchOrder(
rpc: Rpc,
address: Address,
): Promise<OrderAccountWithPda | null> {
return fetchOrderAccount(rpc, address).catch(() => null)
}
export { deriveEscrowId }
```
- [ ] **Step 4: Write unit tests `sdk/src/__tests__/pda.test.ts`**
```ts
import { describe, it, expect } from 'vitest'
import { address } from '@solana/kit'
import { deriveEscrowId, findListingPda } from '../pda'
const SELLER = address('11111111111111111111111111111112')
describe('deriveEscrowId', () => {
it('returns a bigint from the first 8 bytes of the order PDA', () => {
// We test the determinism: same input → same output
const fakeOrderPda = address('So11111111111111111111111111111111111111112')
const id1 = deriveEscrowId(fakeOrderPda)
const id2 = deriveEscrowId(fakeOrderPda)
expect(id1).toBe(id2)
expect(typeof id1).toBe('bigint')
})
it('returns different ids for different PDAs', () => {
const pda1 = address('So11111111111111111111111111111111111111112')
const pda2 = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
expect(deriveEscrowId(pda1)).not.toBe(deriveEscrowId(pda2))
})
})
describe('findListingPda', () => {
it('derives a deterministic PDA for a given seller + listingId', async () => {
const [pda1] = await findListingPda(SELLER, 1001n)
const [pda2] = await findListingPda(SELLER, 1001n)
expect(pda1).toBe(pda2)
expect(pda1).toHaveLength(44) // base58 encoded 32-byte pubkey
})
it('produces different PDAs for different listing ids', async () => {
const [pda1] = await findListingPda(SELLER, 1001n)
const [pda2] = await findListingPda(SELLER, 1002n)
expect(pda1).not.toBe(pda2)
})
})
```
- [ ] **Step 5: Run tests**
```bash
cd sdk && yarn test
```
Expected: 4 passing tests. If `findListingPda` seed params differ from what's in `pda.ts`, fix the wrapper signature based on what Task 3 Step 3 revealed.
- [ ] **Step 6: Type-check**
```bash
cd sdk && yarn typecheck
```
Expected: No errors.
---
### Task 5: SDK `index.ts` + Final Check
**Files:**
- Create: `sdk/src/index.ts`
- [ ] **Step 1: Write `sdk/src/index.ts`**
```ts
// Generated bindings — accounts, instructions, pdas, types, errors, program id
export * from './generated/solisting'
// Hand-written helpers
export * from './pda'
export * from './listing'
export * from './order'
// Re-export Address for consumers
export type { Address, Account } from '@solana/kit'
```
- [ ] **Step 2: Type-check once more**
```bash
cd sdk && yarn typecheck
```
Expected: No errors.
- [ ] **Step 3: Run tests again to confirm nothing regressed**
```bash
cd sdk && yarn test
```
Expected: 4 passing.
- [ ] **Step 4: Commit**
```bash
git add sdk/src/
git commit -m "feat: add @solisting/sdk helpers, tests, and index exports"
```
---
**Next:** Proceed to Part 2 — `2026-06-22-solisting-app-foundation.md`