# 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 1–3 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=` | | `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[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 => { 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=` 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(blank()) const [loading, setLoading] = useState(false) const [error, setError] = useState(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) => 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 (

{isUpdate ? 'Update Listing' : 'Create Listing'}

{isUpdate ? `Editing listing ${editPk?.slice(0, 8)}…` : 'New SOL-priced listing on the solisting program'}

{error &&
{error}
}
) } ``` **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 } export default function CreateListingPage() { return } ``` - [ ] **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 ( 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=` 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 ( ) } ``` **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 ``. **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 (

Wallet required

Connect a wallet to view your listings and orders, and to execute instructions you're authorized for.

) } return (

My Dashboard

{abbrev(account)}

{[ { key: 'listings' as const, label: `My Listings · ${myListings.length}` }, { key: 'orders' as const, label: `My Orders · ${myOrders.length}` }, ].map((t) => ( ))}
{tab === 'listings' && (
{loadingL &&
Loading…
} {!loadingL && myListings.length === 0 && (
You have no listings yet.
)}
{myListings.map((l) => { const sc = STATUS_COLORS[l.data.isActive ? 'active' : 'inactive'] const avail = Number(l.data.quantity) - Number(l.data.quantityReserved) return (
router.push(`/listing/${l.address}`)} style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}>
{l.data.metadataUri || abbrev(l.address)} {l.data.isActive ? 'Active' : 'Inactive'}
#{String(l.data.listingId)} · {fmtSol(l.data.price)} · {avail}/{String(l.data.quantity)} avail · {String(l.data.quantityReserved)} pending
) })}
)} {tab === 'orders' && (
{loadingO &&
Loading…
} {!loadingO && myOrders.length === 0 && (
You have no orders as a buyer.
)}
{myOrders.map((o) => (
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' }} >
{abbrev(o.data.listingAccount)}
#{String(o.data.orderId)} · {fmtSol(o.data.amount)}
#{String(o.data.escrowId)}
))}
)}
) } ``` - [ ] **Step 4: Create `app/src/app/dashboard/page.tsx`** ```tsx import { Dashboard } from '@/components/Dashboard' export default function DashboardPage() { return } ``` - [ ] **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`).