docs: add 4-part Solisting Explorer implementation plan
This commit is contained in:
1156
docs/superpowers/plans/2026-06-22-solisting-app-foundation.md
Normal file
1156
docs/superpowers/plans/2026-06-22-solisting-app-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
1106
docs/superpowers/plans/2026-06-22-solisting-app-read-views.md
Normal file
1106
docs/superpowers/plans/2026-06-22-solisting-app-read-views.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 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=<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`).
|
||||
488
docs/superpowers/plans/2026-06-22-solisting-sdk.md
Normal file
488
docs/superpowers/plans/2026-06-22-solisting-sdk.md
Normal 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`
|
||||
Reference in New Issue
Block a user