Compare commits

...

11 Commits

Author SHA1 Message Date
thesn10
c3097fdc8a add readme 2026-07-08 16:19:15 +02:00
thesn10
aafbc17e94 fix order state 2026-06-27 15:13:36 +02:00
thesn10
aac025e2a8 fix empty resolver 2026-06-27 15:00:06 +02:00
thesn10
e799c0be9d resolver picker 2026-06-27 14:52:11 +02:00
thesn10
0c86751d51 fix escrow handling and fetching 2026-06-26 20:01:36 +02:00
thesn10
02c26e186f dont close on accept order 2026-06-26 19:13:30 +02:00
thesn10
12f1344138 better error handling 2026-06-26 19:13:17 +02:00
thesn10
94d2d67e83 load descro 2026-06-26 19:12:50 +02:00
thesn10
ec1c7d9f8c small fixes 2026-06-26 18:36:34 +02:00
thesn10
555598535d feat: add name and description fields to ListingAccount; update form, table, detail, dashboard 2026-06-25 20:21:28 +02:00
thesn10
ca1c0b1cd8 fix: filter highlight, nav alignment, button centering, listing title, dashboard listing name 2026-06-25 19:59:03 +02:00
36 changed files with 1353 additions and 671 deletions

View File

@@ -18,3 +18,11 @@ wallet = "~/.config/solana/id.json"
test = "cargo test"
[hooks]
[[test.genesis]]
address = "DjVR4EuYV6USMJFfsGZwhZ3y8rtWsmG8EvDY96GTqqi3"
program = "../descro/target/deploy/descro.so"
[[test.genesis]]
address = "GwUPAKs3HHzCpj8uhet4NAnxk9GWNwfrYbpihu5DyFp"
program = "../descro/target/deploy/descro_ext_resolvers.so"

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# solisting
## Vision
Today's marketplaces are gatekept: a company can delist a product, freeze a seller's payout, or shut down the platform outright, and buyers/sellers have no recourse. `solisting` is built on the belief that product listings and the payments behind them should live on-chain instead — where no single operator can censor a listing, and no middleman sits between buyer and seller taking a cut or holding custody.
- 🌍 **Decentralized marketplaces** — listings and orders live as on-chain accounts, not rows in a company's database, so there's no central operator who can arbitrarily delist products or gatekeep who gets to sell.
- 🚫 **No middleman** — buyers and sellers interact directly through the protocol; there's no platform sitting between them extracting fees, controlling access, or acting as a single point of failure.
- 🧱 **Censorship-resistant listings** — as long as the underlying blockchain is live, a listing exists and stays visible; it can't be quietly taken down by a platform decision.
-**Final, auditable payments** — every payment flows through smart contract escrow, so settlement is final and every step of it is publicly verifiable on-chain, not hidden inside a company's internal ledger.
- 🔮 **Predictable by construction** — the rules for how funds move (when they're locked, when they're released, when they're refunded) are enforced by code, not discretionary platform policy, so outcomes are known in advance rather than decided case-by-case.
## What is solisting?
`solisting` is a single-program Solana protocol that implements a **coordination and discovery layer for decentralized product listings and bilateral order consent**. It sits on top of a separate escrow program, [`descro`](../descro), and never custodies funds itself — it CPIs into `descro` to create, confirm, and cancel escrows.
Built using [Anchor](https://www.anchor-lang.com/).
The repository also contains a TypeScript SDK and a Next.js explorer app built on top of the on-chain program.
## Concept
Marketplaces on Solana usually force a choice: either the marketplace program custodies funds itself (more trust assumptions, more surface area to audit), or every integration reinvents escrow from scratch. `solisting` takes a different approach — it is a thin coordination layer that only handles *discovery* (listings) and *consent* (orders), and delegates all fund custody to a dedicated, independently auditable escrow program (`descro`) via CPI.
- 🔐 **Non-custodial by design** — solisting never holds buyer or seller funds; every escrow lifecycle event is a CPI into `descro`, so the fund-custody logic lives in one focused, auditable program instead of being duplicated per marketplace.
- 🧩 **Composable, not monolithic** — separating listings/orders from escrow means `descro` can be reused by other marketplace front-ends, and `solisting` can evolve its discovery/consent UX independently of settlement logic.
- 💱 **Multi-currency listings from one source of truth** — a seller sets a single canonical price; buyers can pay in alternate currencies (including SOL or stablecoins) and the program converts on the fly via Pyth oracles, so there's no need to maintain N parallel prices per listing.
- 🛡️ **Slippage-protected conversion** — buyers supply an expected amount and max slippage tolerance, so oracle-based conversion can't silently overcharge them between quote and execution.
- 🤝 **Bilateral consent, defensively handled** — orders require explicit seller acceptance, and abort paths (`reject_order`/`cancel_order`) tolerate the counterparty already having cancelled directly on `descro`, avoiding stuck states.
## Repository layout
```
programs/solisting/ # Anchor program (Rust)
sdk/ # TypeScript client SDK (Codama-generated + hand-written helpers)
app/ # Next.js app for browsing listings/orders
docs/ # Implementation plans and design docs
vendor/ # Vendored/patched Pyth crates (see "Toolchain gotchas" below)
```
## Architecture
### Canonical price model
`ListingAccount` stores one canonical price (`canonical_currency` + `price`) as the sole source of truth:
- `alt_currencies` — other currencies a buyer may pay in, each with an optional Pyth `TOKEN/USD` oracle (`None` = treated as a $1.00 stablecoin).
- `canonical_oracle` — the Pyth feed for the canonical currency (`None` = canonical is itself a $1 stablecoin).
At `create_order` time, if the buyer pays in an alt currency, the program converts the canonical price into the target currency using USD as a pivot through up to two Pyth feeds, and enforces a buyer-supplied slippage tolerance.
### Accounts & PDAs
- `ListingAccount` — seeds `[b"listing", seller, listing_id]`. Tracks quantity, reservations, accepted resolvers, active state, and metadata URI.
- `OrderAccount` — seeds `[b"order", listing_account, buyer, order_id]`. Records the buyer's chosen payment currency, the oracle-resolved amount, and a pointer to the corresponding `descro` `EscrowAccount`.
### Instructions
| Instruction | Purpose |
|---|---|
| `create_listing` / `update_listing` / `close_listing` | Manage a listing (seller-only) |
| `create_order` | Buyer flow: validates currency/resolver, performs oracle conversion, CPIs into `descro` to create an escrow |
| `accept_order` | Seller flow: confirms the escrow and settles the listing's reserved quantity |
| `reject_order` / `cancel_order` | Seller/buyer-initiated abort, defensive against out-of-band cancellation on `descro` |
| `close_stale_order` | Permissionless cleanup of orders whose escrow has already reached a terminal state |
## Prerequisites
- Rust toolchain `1.89.0` (pinned via `rust-toolchain.toml`)
- Solana CLI / `cargo build-sbf`
- The sibling [`descro`](../descro) repository checked out at `../descro` relative to this repo — solisting path-depends on it and the tests embed its compiled `.so` files.
- Node.js + Yarn (for the SDK and app)
## Building
`descro` (and its dependency `descro_ext_resolvers`) must be built first:
```bash
# 1. Build dependency programs (sibling repo)
cd descro && anchor build
# 2. Build solisting
anchor build
```
## Testing
Requires steps 1 + 2 above to have been run first, since the LiteSVM test harness embeds both `.so` files via `include_bytes!`:
```bash
cargo test --manifest-path programs/solisting/Cargo.toml
# Run one test file / one test by name
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders
cargo test --manifest-path programs/solisting/Cargo.toml -- seller_can_create_sol_only_listing
```
## Lint / format
```bash
cargo clippy --manifest-path programs/solisting/Cargo.toml
cargo fmt --manifest-path programs/solisting/Cargo.toml
```
Note: `Anchor.toml` declares `test = "cargo test"`, but `anchor test` is not the normal workflow here — use the `cargo` commands above directly.
## SDK & app
The `sdk/` and `app/` workspaces are managed with Yarn:
```bash
yarn install
yarn workspace @solisting/sdk ... # SDK-specific commands
```
See their respective `package.json` files and `docs/superpowers/` for details on the SDK and explorer app.
## Toolchain gotchas
- The Rust toolchain is pinned to `1.89.0` via `rust-toolchain.toml`.
- The workspace root `Cargo.toml` patches `pyth-solana-receiver-sdk` and `pythnet-sdk` to local copies under `vendor/` because the crates.io versions don't compile against this toolchain's dependency graph (an `anchor-lang`/`borsh` version conflict). If you touch oracle code, the vendored crates are the source of truth for `PriceUpdateV2`'s shape.

View File

@@ -0,0 +1,8 @@
import { use } from 'react'
import { EscrowDetail } from '@/components/EscrowDetail'
import type { Address } from '@solana/kit'
export default function EscrowPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
return <EscrowDetail pda={pk as Address} />
}

View File

@@ -6,6 +6,7 @@ import { useWallet, useKitTransactionSigner } from '@solana/connector/react'
import { ListingDetail } from '@/components/ListingDetail'
import { useTx } from '@/hooks/useTx'
import { useListing } from '@/hooks/useListing'
import { useResolvers } from '@/hooks/useResolvers'
import {
getCloseListingInstruction,
getCreateOrderInstructionAsync,
@@ -13,6 +14,8 @@ import {
deriveEscrowId,
} from '@solisting/sdk'
import { DESCRO_PROGRAM_ADDRESS } from '@descro/sdk'
import { ResolverType } from '@descro/sdk'
import { Button } from '@/components/ui/Button'
import type { Address } from '@solisting/sdk'
const SYSTEM_PROGRAM = '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>
@@ -24,7 +27,10 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
const { signer } = useKitTransactionSigner()
const sendTx = useTx()
const { data: listing } = useListing(pk as Address)
const { data: resolvers = [] } = useResolvers()
const [orderLoading, setOrderLoading] = useState(false)
const [showResolverPicker, setShowResolverPicker] = useState(false)
const [selectedResolver, setSelectedResolver] = useState<Address | null>(null)
async function handleClose() {
if (!signer) return
@@ -33,8 +39,14 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
router.push('/listings')
}
async function handlePlaceOrder() {
function openPlaceOrder() {
setSelectedResolver(null)
setShowResolverPicker(true)
}
async function handleConfirmOrder() {
if (!signer || !account || !listing) return
setShowResolverPicker(false)
setOrderLoading(true)
try {
const orderId = BigInt(Date.now())
@@ -50,7 +62,7 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
descroProgram: DESCRO_PROGRAM_ADDRESS,
orderId,
escrowId,
resolver: account as Address,
resolver: selectedResolver,
paymentCurrency: { __kind: 'Sol' },
expectedAmount: listing.data.price,
maxSlippageBps: 0,
@@ -60,20 +72,108 @@ export default function ListingPage({ params }: { params: Promise<{ pk: string }
[['listing', pk], ['orders', pk]],
'create_order',
)
} catch (err) {
console.error('create_order failed:', err)
} catch {
// error already shown via toast in useTx
} finally {
setOrderLoading(false)
}
}
const acceptedResolvers = listing?.data.acceptedResolvers ?? []
const visibleResolvers = acceptedResolvers.length > 0
? resolvers.filter((r) => acceptedResolvers.includes(r.pda as Address))
: resolvers
const mustSelectResolver = acceptedResolvers.length > 0
const confirmDisabled = mustSelectResolver && !selectedResolver
return (
<ListingDetail
pk={pk as Address}
walletAddress={account ?? null}
onUpdate={() => router.push(`/listing/create?edit=${pk}`)}
onClose={handleClose}
onPlaceOrder={orderLoading ? undefined : handlePlaceOrder}
/>
<>
<ListingDetail
pk={pk as Address}
walletAddress={account ?? null}
onUpdate={() => router.push(`/listing/create?edit=${pk}`)}
onClose={handleClose}
onPlaceOrder={orderLoading ? undefined : openPlaceOrder}
/>
{showResolverPicker && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}
onClick={(e) => { if (e.target === e.currentTarget) setShowResolverPicker(false) }}
>
<div style={{ background: 'var(--bg)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '28px 28px 24px', width: 440, maxWidth: '90vw', maxHeight: '80vh', overflow: 'auto' }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 12 }}>PLACE ORDER</div>
<h2 style={{ margin: '0 0 8px', fontSize: 19, fontWeight: 700 }}>Select Resolver</h2>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--mut)', lineHeight: 1.55 }}>
{mustSelectResolver
? 'This listing requires one of the following resolvers for dispute resolution.'
: 'Choose a resolver for dispute resolution, or proceed without one.'}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 22 }}>
{!mustSelectResolver && (
<ResolverOption
selected={selectedResolver === null}
onClick={() => setSelectedResolver(null)}
label="No resolver"
sublabel="Disputes cannot be resolved on-chain"
/>
)}
{visibleResolvers.map((r) => (
<ResolverOption
key={r.pda}
selected={selectedResolver === r.pda}
onClick={() => setSelectedResolver(r.pda as Address)}
label={`${r.pda.slice(0, 8)}${r.pda.slice(-6)}`}
sublabel={ResolverType[r.data.resolverType as number] as string}
mono
/>
))}
{visibleResolvers.length === 0 && mustSelectResolver && (
<div style={{ fontSize: 13, color: 'var(--mut)', padding: '12px 0' }}>
No registered resolvers found for this listing.
</div>
)}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Button variant="ghost" onClick={() => setShowResolverPicker(false)}>Cancel</Button>
<Button onClick={handleConfirmOrder} disabled={confirmDisabled}>Place Order</Button>
</div>
</div>
</div>
)}
</>
)
}
function ResolverOption({ selected, onClick, label, sublabel, mono }: {
selected: boolean
onClick: () => void
label: string
sublabel?: string
mono?: boolean
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '11px 14px', borderRadius: 9, cursor: 'pointer', textAlign: 'left',
background: selected ? 'var(--accSoft)' : 'var(--bg3)',
border: `1px solid ${selected ? 'var(--accBd)' : 'var(--bd)'}`,
color: 'var(--tx)',
transition: 'background .12s, border-color .12s',
}}
>
<span style={{ fontFamily: mono ? 'var(--font-mono)' : undefined, fontSize: mono ? 12.5 : 13, fontWeight: selected ? 600 : mono ? 400 : 600 }}>
{label}
</span>
{sublabel && (
<span style={{ fontSize: 11.5, color: 'var(--mut)', flexShrink: 0 }}>
{sublabel}
</span>
)}
</button>
)
}

View File

@@ -17,8 +17,11 @@ import {
DESCRO_PROGRAM_ADDRESS,
findResolverEntryPda,
} from '@descro/sdk'
import { isSome } from '@solana/kit'
import type { Address } from '@solisting/sdk'
const SYSTEM_PROGRAM = '11111111111111111111111111111111' as Address
export default function OrderPage({ params }: { params: Promise<{ pk: string }> }) {
const { pk } = use(params)
const { account } = useWallet()
@@ -31,10 +34,14 @@ export default function OrderPage({ params }: { params: Promise<{ pk: string }>
async function handleAccept() {
if (!signer || !data) return
const od = data.order.data
const [resolverEntry] = await findResolverEntryPda({ authority: od.resolver })
const resolverAddr = isSome(od.resolver) ? od.resolver.value : null
const resolverKey = resolverAddr ?? SYSTEM_PROGRAM
const [resolverEntry] = resolverAddr
? await findResolverEntryPda({ authority: resolverAddr })
: [SYSTEM_PROGRAM]
const ix = getAcceptOrderInstruction({
seller: signer,
resolver: od.resolver,
resolver: resolverKey,
listingAccount: od.listing,
orderAccount: pk as Address,
escrowAccount: od.escrowAccount,

View File

@@ -26,6 +26,8 @@ interface FormState {
price: string
quantity: string
oracle: string
name: string
description: string
metadataUri: string
alts: AltEntry[]
resolvers: string[]
@@ -39,6 +41,8 @@ function blank(): FormState {
price: '',
quantity: '',
oracle: '',
name: '',
description: '',
metadataUri: '',
alts: [],
resolvers: [],
@@ -87,6 +91,9 @@ function CurrencyToggle({
}) {
const active = (on: boolean): React.CSSProperties => ({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 11,
borderRadius: 10,
fontSize: 13,
@@ -94,7 +101,7 @@ function CurrencyToggle({
cursor: 'pointer',
color: on ? '#0b0613' : 'var(--mut)',
background: on ? 'linear-gradient(135deg,var(--acc),var(--acc2))' : 'var(--bg3)',
border: `1px solid ${on ? 'transparent' : 'var(--bd)'}`,
border: `1px solid var(--bd)`,
})
return (
<div style={{ display: 'flex', gap: 8 }}>
@@ -145,6 +152,8 @@ export function CreateListingForm({ editPk }: Props) {
: (Number(d.price) / 1e9).toString(),
quantity: String(d.quantity),
oracle: isSome(d.canonicalOracle) ? d.canonicalOracle.value : '',
name: d.name ?? '',
description: d.description ?? '',
metadataUri: d.metadataUri ?? '',
alts: d.altCurrencies.map((a) => ({
currency:
@@ -262,6 +271,8 @@ export function CreateListingForm({ editPk }: Props) {
altCurrencies,
acceptedResolvers,
quantity: quantityN,
name: form.name,
description: form.description,
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings'], ['listing', editPk]], 'update_listing')
@@ -275,6 +286,8 @@ export function CreateListingForm({ editPk }: Props) {
altCurrencies,
acceptedResolvers,
quantity: quantityN,
name: form.name,
description: form.description,
metadataUri: form.metadataUri,
})
await sendTx([ix as never], [['listings']], 'create_listing')
@@ -334,6 +347,16 @@ export function CreateListingForm({ editPk }: Props) {
gap: 22,
}}
>
{/* NAME + DESCRIPTION */}
<div>
<label style={LABEL}>NAME <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 64 chars)</span></label>
<input value={form.name} onChange={set('name')} placeholder="e.g. Mechanical Keyboard Kit" style={INPUT} maxLength={64} />
</div>
<div>
<label style={LABEL}>DESCRIPTION <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span></label>
<input value={form.description} onChange={set('description')} placeholder="Short description of what you're selling" style={INPUT} maxLength={256} />
</div>
{/* CANONICAL CURRENCY */}
<div>
<label style={LABEL}>CANONICAL CURRENCY</label>

View File

@@ -168,7 +168,7 @@ export function Dashboard() {
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontWeight: 600, fontSize: 15 }}>
{l.data.metadataUri || abbrev(l.address)}
{l.data.name || `Listing #${String(l.data.listingId)}`}
</span>
<Badge color={sc.color} bg={sc.bg}>
{l.data.isActive ? 'Active' : 'Inactive'}
@@ -182,7 +182,7 @@ export function Dashboard() {
fontFamily: 'var(--font-mono)',
}}
>
#{String(l.data.listingId)} · {fmtSol(l.data.price)} · {avail}/
{abbrev(l.address)} · {fmtSol(l.data.price)} · {avail}/
{String(l.data.quantity)} avail · {String(l.data.quantityReserved)} pending
</div>
</div>

View File

@@ -0,0 +1,110 @@
'use client'
import { useRouter } from 'next/navigation'
import { isSome } from '@solana/kit'
import { escrowStateLabel } from '@descro/sdk'
import { EscrowState } from '@descro/sdk/src/generated/descro/src/generated/types/escrowState'
import { useEscrow } from '@/hooks/useEscrow'
import { useOrderByEscrow } from '@/hooks/useOrderByEscrow'
import { EscrowStateMachine } from '@/components/EscrowStateMachine'
import { FieldRow, MonoChip } from '@/components/ui/FieldRow'
import { Badge, STATUS_COLORS } from '@/components/ui/Badge'
import { abbrev, fmtSol } from '@/lib/format'
import type { Address } from '@solana/kit'
function escrowStatusKey(state: EscrowState): keyof typeof STATUS_COLORS {
switch (state) {
case EscrowState.AwaitingSellerConfirm: return 'awaitingConfirm'
case EscrowState.Active: return 'active'
case EscrowState.Disputed: return 'disputed'
case EscrowState.Complete: return 'complete'
default: return 'cancelled'
}
}
interface Props {
pda: Address
}
export function EscrowDetail({ pda }: Props) {
const router = useRouter()
const { data: escrow, isLoading, error } = useEscrow(pda)
const { data: order } = useOrderByEscrow(pda)
if (isLoading) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Loading</div>
if (error || !escrow) return <div style={{ padding: 48, textAlign: 'center', color: 'var(--mut)' }}>Escrow account not found.</div>
const ed = escrow.data
const stateLabel = escrowStateLabel(ed.state)
const sc = STATUS_COLORS[escrowStatusKey(ed.state)]
const resolverPk = isSome(ed.disputeResolver) ? ed.disputeResolver.value : null
return (
<div>
<button
onClick={() => router.push('/escrows')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--mut)', fontSize: 13, fontWeight: 500, marginBottom: 18 }}
>
Escrows
</button>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', color: 'var(--mut)', marginBottom: 5 }}>
ESCROW ACCOUNT · #{String(ed.escrowId)}
</div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>{abbrev(pda)}</h1>
</div>
<Badge color={sc.color} bg={sc.bg}>{stateLabel}</Badge>
</div>
<EscrowStateMachine state={ed.state} />
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '8px 22px 16px' }}>
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '14px 0 4px' }}>
ESCROW ACCOUNT · descro
</div>
<FieldRow label="Address">
<MonoChip value={abbrev(pda)} onCopy={() => navigator.clipboard.writeText(pda)} />
</FieldRow>
<FieldRow label="State">
<Badge color={sc.color} bg={sc.bg}>{stateLabel}</Badge>
</FieldRow>
<FieldRow label="Escrow ID">
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>#{String(ed.escrowId)}</span>
</FieldRow>
<FieldRow label="Seller">
<MonoChip value={abbrev(ed.seller)} onCopy={() => navigator.clipboard.writeText(ed.seller)} />
</FieldRow>
<FieldRow label="Buyer">
<MonoChip value={abbrev(ed.buyer)} onCopy={() => navigator.clipboard.writeText(ed.buyer)} />
</FieldRow>
<FieldRow label="Amount">
<span style={{ fontSize: 13.5, fontWeight: 600 }}>{fmtSol(ed.amount)}</span>
</FieldRow>
{resolverPk && (
<FieldRow label="Resolver">
<MonoChip
value={abbrev(resolverPk)}
onClick={() => router.push(`/resolver/${resolverPk}`)}
onCopy={() => navigator.clipboard.writeText(resolverPk)}
/>
</FieldRow>
)}
<FieldRow label="Linked Order" last>
{order
? (
<MonoChip
value={abbrev(order.address)}
onClick={() => router.push(`/order/${order.address}`)}
onCopy={() => navigator.clipboard.writeText(order.address)}
/>
)
: <span style={{ fontSize: 13, color: 'var(--mut)' }}></span>
}
</FieldRow>
</div>
</div>
)
}

View File

@@ -82,8 +82,8 @@ export function EscrowsTable() {
borderRadius: 7,
fontSize: 12.5,
fontWeight: 600,
color: filter === f.key ? 'var(--tx)' : 'var(--mut)',
background: filter === f.key ? 'var(--bg2)' : 'transparent',
color: filter === f.key ? '#0b0613' : 'var(--mut)',
background: filter === f.key ? 'linear-gradient(135deg,var(--acc),var(--acc2))' : 'transparent',
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
@@ -137,7 +137,7 @@ export function EscrowsTable() {
return (
<div
key={e.pda}
onClick={() => router.push(`/search?q=${e.pda}`)}
onClick={() => router.push(`/escrow/${e.pda}`)}
style={{
display: 'grid',
gridTemplateColumns: '1.2fr 1.4fr 1.4fr .9fr 1.3fr .9fr',

View File

@@ -54,30 +54,40 @@ export function ListingDetail({ pk, walletAddress, onUpdate, onClose, onPlaceOrd
LISTING ACCOUNT · #{String(d.listingId)}
</div>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
{d.metadataUri || abbrev(pk)}
{d.name || abbrev(pk)}
</h1>
</div>
<Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
{(d.description || d.metadataUri) && (
<p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--mut)', maxWidth: 620, lineHeight: 1.55 }}>
{d.description || (
<a href={d.metadataUri} target="_blank" rel="noreferrer" style={{ color: 'var(--acc2light)' }}>{d.metadataUri}</a>
)}
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: 18, alignItems: 'start', marginBottom: 24 }}>
{/* Left: account fields */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: 'var(--radius)', padding: '8px 22px 16px' }}>
<div style={{ fontSize: 11, letterSpacing: '.13em', color: 'var(--mut)', fontWeight: 600, padding: '14px 0 4px' }}>ACCOUNT FIELDS</div>
<FieldRow label="Listing ID"><span style={{ fontSize: 13.5, fontWeight: 600 }}>#{String(d.listingId)}</span></FieldRow>
<FieldRow label="Address"><MonoChip value={abbrev(pk)} onCopy={() => navigator.clipboard.writeText(pk)} /></FieldRow>
<FieldRow label="Seller"><MonoChip value={abbrev(d.seller)} onCopy={() => navigator.clipboard.writeText(d.seller)} onClick={() => router.push(`/search?q=${d.seller}`)} /></FieldRow>
<FieldRow label="Price"><span style={{ fontSize: 13.5, fontWeight: 600 }}>{fmtListingPrice()}</span></FieldRow>
<FieldRow label="Currency">
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5 }}>
{d.canonicalCurrency.__kind === 'Sol' ? 'SOL (native)' : `SPL · ${abbrev((d.canonicalCurrency as { mint: string }).mint)}`}
<FieldRow label="Listing ID"><span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>#{String(d.listingId)}</span></FieldRow>
<FieldRow label="Canonical Currency">
<span style={{ fontWeight: 600, fontSize: 13 }}>
{d.canonicalCurrency.__kind === 'Sol' ? 'SOL' : `SPL · ${abbrev((d.canonicalCurrency as { mint: string }).mint)}`}
</span>
</FieldRow>
{canonicalOracleAddr && (
<FieldRow label="Oracle">
<MonoChip value={abbrev(canonicalOracleAddr)} onCopy={() => navigator.clipboard.writeText(canonicalOracleAddr)} />
</FieldRow>
)}
<FieldRow label="Price"><span style={{ fontWeight: 700, fontSize: 13.5 }}>{fmtListingPrice()}</span></FieldRow>
<FieldRow label="Canonical Oracle">
{canonicalOracleAddr
? <MonoChip value={abbrev(canonicalOracleAddr)} onCopy={() => navigator.clipboard.writeText(canonicalOracleAddr)} />
: <span style={{ fontSize: 13, color: 'var(--mut)' }}>None priced directly</span>
}
</FieldRow>
<FieldRow label="Status"><Badge color={sc.color} bg={sc.bg}>{d.isActive ? 'Active' : 'Inactive'}</Badge></FieldRow>
<FieldRow label="Bump"><span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--mut)' }}>{d.bump}</span></FieldRow>
{d.metadataUri && (
<>
@@ -137,7 +147,7 @@ export function ListingDetail({ pk, walletAddress, onUpdate, onClose, onPlaceOrd
<Button variant="danger" onClick={onClose}>Close Listing</Button>
</div>
)}
{canPlace && <Button onClick={onPlaceOrder}>Place Order</Button>}
{canPlace && <Button style={{ width: '100%', padding: 11 }} onClick={onPlaceOrder}>Place Order</Button>}
{!walletAddress && <div style={{ fontSize: 13, color: 'var(--mut)', lineHeight: 1.5 }}>Connect a wallet to place an order or manage this listing.</div>}
{walletAddress && !isSeller && !canPlace && <div style={{ fontSize: 13, color: 'var(--mut)', lineHeight: 1.5 }}>No actions available listing is inactive or out of stock.</div>}
</div>

View File

@@ -59,15 +59,27 @@ export function ListingsTable() {
style={{ height: 38, width: 220, padding: '0 13px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, fontSize: 13, fontFamily: 'var(--font-mono)', outline: 'none' }}
/>
<div style={{ display: 'flex', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 3 }}>
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{ padding: '6px 13px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, color: filter === f.key ? 'var(--tx)' : 'var(--mut)', background: filter === f.key ? 'var(--bg2)' : 'transparent' }}
>
{f.label}
</button>
))}
{FILTERS.map((f) => {
const active = filter === f.key
return (
<button
key={f.key}
onClick={() => setFilter(f.key)}
style={{
padding: '6px 13px',
borderRadius: 7,
fontSize: 12.5,
fontWeight: 600,
color: active ? '#0b0613' : 'var(--mut)',
background: active ? 'linear-gradient(135deg,var(--acc),var(--acc2))' : 'transparent',
border: 'none',
cursor: 'pointer',
}}
>
{f.label}
</button>
)
})}
</div>
</div>
</div>
@@ -96,7 +108,7 @@ export function ListingsTable() {
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{l.data.metadataUri || abbrev(l.address)}
{l.data.name || abbrev(l.address)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--mut)', marginTop: 3 }}>
#{String(l.data.listingId)} · {abbrev(l.address)}

View File

@@ -147,6 +147,9 @@ export function Nav() {
})}
</div>
{/* Spacer — pushes right-side controls to the edge */}
<div style={{ flex: 1 }} />
{/* Network picker */}
<div ref={netRef} style={{ position: 'relative', flexShrink: 0 }}>
<button

View File

@@ -44,7 +44,7 @@ export function OrderDetail({ pk, walletAddress, onAccept, onReject, onCancel, o
const od = order.data
const ed = escrow?.data
const stateEnum = ed?.state ?? EscrowState.Cancelled
const stateEnum = ed?.state ?? EscrowState.Complete
const stateLabel = escrowStateLabel(stateEnum)
const sc = STATUS_COLORS[escrowStatusKey(stateEnum)]

View File

@@ -36,6 +36,9 @@ export function Button({ variant = 'primary', children, style, disabled, ...rest
{...rest}
disabled={disabled}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '11px 18px',
borderRadius: 'var(--radius)',
fontWeight: 700,

View File

@@ -2,24 +2,26 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
const ToastCtx = createContext<(msg: string) => void>(() => {})
type ToastFn = (msg: string, type?: 'success' | 'error') => void
const ToastCtx = createContext<ToastFn>(() => {})
export function useToast() {
return useContext(ToastCtx)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [msg, setMsg] = useState('')
const [state, setState] = useState<{ msg: string; type: 'success' | 'error' } | null>(null)
const show = useCallback((m: string) => {
setMsg(m)
setTimeout(() => setMsg(''), 2800)
const show = useCallback<ToastFn>((m, type = 'success') => {
setState({ msg: m, type })
setTimeout(() => setState(null), type === 'error' ? 4500 : 2800)
}, [])
return (
<ToastCtx.Provider value={show}>
{children}
{msg && (
{state && (
<div
style={{
position: 'fixed',
@@ -32,7 +34,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
gap: 10,
padding: '12px 18px',
background: 'var(--bg2)',
border: '1px solid var(--accBd)',
border: `1px solid ${state.type === 'error' ? 'var(--errBd, #f87171)' : 'var(--accBd)'}`,
borderRadius: 12,
boxShadow: '0 16px 40px rgba(0,0,0,.55)',
fontSize: 13,
@@ -46,12 +48,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--acc2)',
boxShadow: '0 0 8px var(--acc2)',
background: state.type === 'error' ? '#f87171' : 'var(--acc2)',
boxShadow: state.type === 'error' ? '0 0 8px #f87171' : '0 0 8px var(--acc2)',
flexShrink: 0,
}}
/>
{msg}
{state.msg}
</div>
)}
</ToastCtx.Provider>

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchEscrowAccount } from '@descro/sdk'
import type { Address } from '@solana/kit'
export function useEscrow(pda: Address) {
const { cluster } = useCluster()
return useQuery({
queryKey: ['escrow', pda, cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchEscrowAccount(
rpc as Parameters<typeof fetchEscrowAccount>[0],
pda,
).catch(() => null)
},
enabled: !!cluster && !!pda,
refetchInterval: 15_000,
})
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query'
import { useCluster } from '@solana/connector/react'
import { createSolanaRpc } from '@solana/kit'
import { fetchOrderByEscrow } from '@solisting/sdk'
import type { Address } from '@solana/kit'
export function useOrderByEscrow(escrowPda: Address) {
const { cluster } = useCluster()
return useQuery({
queryKey: ['orderByEscrow', escrowPda, cluster?.id],
queryFn: () => {
const rpc = createSolanaRpc(cluster!.url)
return fetchOrderByEscrow(rpc, escrowPda)
},
enabled: !!cluster && !!escrowPda,
refetchInterval: 30_000,
})
}

View File

@@ -39,10 +39,15 @@ export function useTx() {
const signed = await signTransactionMessageWithSigners(txMsg)
assertIsTransactionWithBlockhashLifetime(signed)
await sendAndConfirmTransactionFactory({ rpc: rpc as never, rpcSubscriptions: rpcSubscriptions as never })(
signed as never,
{ commitment: 'confirmed' },
)
try {
await sendAndConfirmTransactionFactory({ rpc: rpc as never, rpcSubscriptions: rpcSubscriptions as never })(
signed as never,
{ commitment: 'confirmed' },
)
} catch (err) {
toast(friendlyTxError(err), 'error')
throw err
}
toast(`Tx confirmed — ${label}`)
for (const key of invalidateKeys) {
@@ -50,3 +55,20 @@ export function useTx() {
}
}
}
function friendlyTxError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err)
if (
msg.includes('Attempt to debit an account but found no record of a prior credit') ||
msg.includes('AccountNotFound')
) {
return 'Insufficient SOL — please fund your wallet and try again.'
}
if (msg.includes('insufficient lamports') || msg.includes('insufficient funds')) {
return 'Insufficient SOL — please fund your wallet and try again.'
}
if (msg.includes('User rejected') || msg.includes('Transaction was not confirmed')) {
return 'Transaction cancelled.'
}
return 'Transaction failed — please try again.'
}

View File

@@ -23,7 +23,6 @@ pub struct AcceptOrder<'info> {
seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
bump = order_account.bump,
constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
close = seller,
)]
pub order_account: Account<'info, OrderAccount>,

View File

@@ -29,6 +29,8 @@ pub fn handler(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
@@ -40,6 +42,8 @@ pub fn handler(
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.quantity_reserved = 0;
listing.name = name;
listing.description = description;
listing.metadata_uri = metadata_uri;
listing.listing_id = listing_id;
listing.is_active = true;

View File

@@ -4,7 +4,7 @@ use crate::state::{Currency, ListingAccount, OrderAccount};
use anchor_lang::prelude::*;
#[derive(Accounts)]
#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: Currency)]
#[instruction(order_id: u64, escrow_id: u64, resolver: Option<Pubkey>, payment_currency: Currency)]
pub struct CreateOrder<'info> {
#[account(mut)]
pub buyer: Signer<'info>,
@@ -67,7 +67,7 @@ pub fn handler(
ctx: Context<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
payment_currency: Currency,
expected_amount: u64,
max_slippage_bps: u16,
@@ -80,10 +80,13 @@ pub fn handler(
let listing = &ctx.accounts.listing_account;
if !listing.accepted_resolvers.is_empty() {
require!(
listing.accepted_resolvers.contains(&resolver),
SolistingError::ResolverNotAccepted
);
match resolver {
Some(r) => require!(
listing.accepted_resolvers.contains(&r),
SolistingError::ResolverNotAccepted
),
None => return err!(SolistingError::ResolverNotAccepted),
}
}
let amount = if payment_currency == listing.canonical_currency {
@@ -143,7 +146,7 @@ pub fn handler(
},
),
amount,
Some(resolver),
resolver,
escrow_id,
)?;

View File

@@ -25,6 +25,8 @@ pub fn handler(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
@@ -34,6 +36,8 @@ pub fn handler(
listing.alt_currencies = alt_currencies;
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.name = name;
listing.description = description;
listing.metadata_uri = metadata_uri;
Ok(())
}

View File

@@ -31,11 +31,14 @@ pub mod solisting {
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
create_listing::handler(
ctx, listing_id, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
canonical_oracle, alt_currencies, accepted_resolvers, quantity,
name, description, metadata_uri,
)
}
@@ -48,11 +51,14 @@ pub mod solisting {
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Result<()> {
update_listing::handler(
ctx, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
canonical_oracle, alt_currencies, accepted_resolvers, quantity,
name, description, metadata_uri,
)
}
@@ -64,7 +70,7 @@ pub mod solisting {
ctx: Context<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
payment_currency: state::Currency,
expected_amount: u64,
max_slippage_bps: u16,

View File

@@ -52,6 +52,10 @@ pub struct ListingAccount {
pub quantity: u32,
/// Units held by pending orders. available = quantity - quantity_reserved.
pub quantity_reserved: u32,
#[max_len(64)]
pub name: String,
#[max_len(256)]
pub description: String,
#[max_len(256)]
pub metadata_uri: String,
pub listing_id: u64,
@@ -65,7 +69,7 @@ pub struct OrderAccount {
pub listing: Pubkey,
pub buyer: Pubkey,
pub seller: Pubkey,
pub resolver: Pubkey,
pub resolver: Option<Pubkey>,
/// The currency the buyer chose to pay in.
pub payment_currency: Currency,
/// Actual amount paid (may differ from price when oracle-converted).

View File

@@ -101,6 +101,8 @@ pub fn ix_create_listing(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Instruction {
let listing_account = listing_pda(seller, listing_id);
@@ -114,6 +116,8 @@ pub fn ix_create_listing(
alt_currencies,
accepted_resolvers,
quantity,
name,
description,
metadata_uri,
}
.data(),
@@ -136,6 +140,8 @@ pub fn ix_update_listing(
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
name: String,
description: String,
metadata_uri: String,
) -> Instruction {
let listing_account = listing_pda(seller, listing_id);
@@ -148,6 +154,8 @@ pub fn ix_update_listing(
alt_currencies,
accepted_resolvers,
quantity,
name,
description,
metadata_uri,
}
.data(),
@@ -187,7 +195,6 @@ pub fn ix_create_order(
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
) -> Instruction {
let resolver = Pubkey::new_unique();
ix_create_order_with_resolver(
buyer,
seller,
@@ -197,7 +204,7 @@ pub fn ix_create_order(
max_slippage_bps,
canonical_oracle,
target_oracle,
resolver,
Some(Pubkey::new_unique()),
)
}
@@ -211,7 +218,7 @@ pub fn ix_create_order_with_resolver(
max_slippage_bps: u16,
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
resolver: Pubkey,
resolver: Option<Pubkey>,
) -> Instruction {
let listing = listing_pda(seller, listing_id);
let order = order_pda(listing, *buyer, order_id);
@@ -256,7 +263,7 @@ pub fn ix_accept_order(
buyer: &Pubkey,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
) -> Instruction {
let listing_account = listing_pda(seller, listing_id);
let order_account = order_pda(listing_account, *buyer, order_id);
@@ -268,7 +275,7 @@ pub fn ix_accept_order(
&solisting::instruction::AcceptOrder {}.data(),
solisting::accounts::AcceptOrder {
seller: *seller,
resolver,
resolver: resolver.unwrap_or(system_program::ID),
listing_account,
order_account,
escrow_account,

View File

@@ -17,7 +17,7 @@ fn seller_can_create_sol_only_listing() {
vec![],
vec![],
10,
"ipfs://test".to_string(),
"".to_string(), "".to_string(), "ipfs://test".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -48,7 +48,7 @@ fn seller_can_create_listing_with_usdc_alt_stablecoin() {
}],
vec![],
5,
"ipfs://x".to_string(),
"".to_string(), "".to_string(), "ipfs://x".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -75,7 +75,7 @@ fn seller_can_create_listing_with_bonk_alt_oracle() {
}],
vec![],
5,
"".to_string(),
"".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -90,7 +90,7 @@ fn seller_can_update_listing() {
let listing_id: u64 = 2;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000,
None, vec![], vec![], 10, "".to_string(),
None, vec![], vec![], 10, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -103,7 +103,7 @@ fn seller_can_update_listing() {
vec![],
vec![],
20,
"ipfs://new".to_string(),
"".to_string(), "".to_string(), "ipfs://new".to_string(),
);
send(&mut svm, &[ix_update], &[&seller]);
@@ -118,7 +118,7 @@ fn seller_can_close_listing() {
let listing_id: u64 = 3;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000,
None, vec![], vec![], 5, "".to_string(),
None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);

View File

@@ -10,7 +10,7 @@ fn buyer_can_create_order_canonical_sol() {
let price = 100_000_000u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, price,
None, vec![], vec![], 5, "".to_string(),
None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -42,7 +42,7 @@ fn create_order_fails_if_listing_inactive() {
let listing_id = 2u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000,
None, vec![], vec![], 1, "".to_string(),
None, vec![], vec![], 1, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
let ix_close = ix_close_listing(&seller.pubkey(), listing_id);
@@ -62,7 +62,7 @@ fn create_order_fails_if_out_of_stock() {
let listing_id = 3u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 100_000_000,
None, vec![], vec![], 1, "".to_string(),
None, vec![], vec![], 1, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
send(
@@ -87,7 +87,7 @@ fn create_order_fails_if_currency_not_accepted() {
let listing_id = 4u64;
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 100_000_000,
None, vec![], vec![], 5, "".to_string(),
None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -110,7 +110,7 @@ fn create_order_fails_if_resolver_not_accepted() {
let allowed_resolver = Pubkey::new_unique();
let ix = ix_create_listing(
&seller.pubkey(), listing_id, Currency::Sol, 100_000_000,
None, vec![], vec![allowed_resolver], 5, "".to_string(),
None, vec![], vec![allowed_resolver], 5, "".to_string(), "".to_string(), "".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
@@ -118,7 +118,7 @@ fn create_order_fails_if_resolver_not_accepted() {
&mut svm,
&[ix_create_order_with_resolver(
&buyer.pubkey(), &seller.pubkey(), listing_id, 1,
Currency::Sol, 0, None, None, Pubkey::new_unique(),
Currency::Sol, 0, None, None, Some(Pubkey::new_unique()),
)],
&[&buyer],
);
@@ -132,7 +132,7 @@ fn seller_accept_creates_active_descro_escrow() {
let price = 100_000_000u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
let order_id = 1u64;
@@ -152,7 +152,7 @@ fn seller_accept_creates_active_descro_escrow() {
);
let order_key = order_pda(listing_key, buyer.pubkey(), order_id);
assert!(svm.get_account(&order_key).is_none());
assert!(svm.get_account(&order_key).is_some());
let listing = read_listing(&svm, &seller.pubkey(), listing_id);
assert_eq!(listing.quantity_reserved, 0);
@@ -166,7 +166,7 @@ fn seller_can_reject_order() {
let price = 100_000_000u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -197,7 +197,7 @@ fn buyer_can_cancel_order() {
let listing_id = 21u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -223,7 +223,7 @@ fn reject_handles_already_cancelled_escrow() {
let listing_id = 22u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -250,7 +250,7 @@ fn anyone_can_close_stale_order_after_terminal_escrow() {
let listing_id = 30u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(
@@ -284,7 +284,7 @@ fn close_stale_order_fails_if_escrow_still_active() {
let listing_id = 31u64;
send(
&mut svm,
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())],
&[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string(), "".to_string(), "".to_string())],
&[&seller],
);
send(

View File

@@ -96,6 +96,8 @@ export type SolistingStateListingAccount = {
quantity: number;
/** Units held by pending orders. available = quantity - quantity_reserved. */
quantityReserved: number;
name: string;
description: string;
metadataUri: string;
listingId: bigint;
isActive: boolean;
@@ -125,6 +127,8 @@ export type SolistingStateListingAccountArgs = {
quantity: number;
/** Units held by pending orders. available = quantity - quantity_reserved. */
quantityReserved: number;
name: string;
description: string;
metadataUri: string;
listingId: number | bigint;
isActive: boolean;
@@ -147,6 +151,8 @@ export function getSolistingStateListingAccountEncoder(): Encoder<SolistingState
["acceptedResolvers", getArrayEncoder(getAddressEncoder())],
["quantity", getU32Encoder()],
["quantityReserved", getU32Encoder()],
["name", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["description", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["metadataUri", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["listingId", getU64Encoder()],
["isActive", getBooleanEncoder()],
@@ -174,6 +180,8 @@ export function getSolistingStateListingAccountDecoder(): Decoder<SolistingState
["acceptedResolvers", getArrayDecoder(getAddressDecoder())],
["quantity", getU32Decoder()],
["quantityReserved", getU32Decoder()],
["name", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["description", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["metadataUri", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["listingId", getU64Decoder()],
["isActive", getBooleanDecoder()],

View File

@@ -21,6 +21,8 @@ import {
getBytesEncoder,
getI64Decoder,
getI64Encoder,
getOptionDecoder,
getOptionEncoder,
getStructDecoder,
getStructEncoder,
getU64Decoder,
@@ -38,6 +40,8 @@ import {
type FetchAccountsConfig,
type MaybeAccount,
type MaybeEncodedAccount,
type Option,
type OptionOrNullable,
type ReadonlyUint8Array,
} from "@solana/kit";
import {
@@ -61,7 +65,7 @@ export type SolistingStateOrderAccount = {
listing: Address;
buyer: Address;
seller: Address;
resolver: Address;
resolver: Option<Address>;
/** The currency the buyer chose to pay in. */
paymentCurrency: SolistingStateCurrency;
/** Actual amount paid (may differ from price when oracle-converted). */
@@ -79,7 +83,7 @@ export type SolistingStateOrderAccountArgs = {
listing: Address;
buyer: Address;
seller: Address;
resolver: Address;
resolver: OptionOrNullable<Address>;
/** The currency the buyer chose to pay in. */
paymentCurrency: SolistingStateCurrencyArgs;
/** Actual amount paid (may differ from price when oracle-converted). */
@@ -101,7 +105,7 @@ export function getSolistingStateOrderAccountEncoder(): Encoder<SolistingStateOr
["listing", getAddressEncoder()],
["buyer", getAddressEncoder()],
["seller", getAddressEncoder()],
["resolver", getAddressEncoder()],
["resolver", getOptionEncoder(getAddressEncoder())],
["paymentCurrency", getSolistingStateCurrencyEncoder()],
["amount", getU64Encoder()],
["escrowAccount", getAddressEncoder()],
@@ -124,7 +128,7 @@ export function getSolistingStateOrderAccountDecoder(): Decoder<SolistingStateOr
["listing", getAddressDecoder()],
["buyer", getAddressDecoder()],
["seller", getAddressDecoder()],
["resolver", getAddressDecoder()],
["resolver", getOptionDecoder(getAddressDecoder())],
["paymentCurrency", getSolistingStateCurrencyDecoder()],
["amount", getU64Decoder()],
["escrowAccount", getAddressDecoder()],

View File

@@ -111,6 +111,8 @@ export type CreateListingInstructionData = {
altCurrencies: Array<SolistingStateAltCurrencyConfig>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -122,6 +124,8 @@ export type CreateListingInstructionDataArgs = {
altCurrencies: Array<SolistingStateAltCurrencyConfigArgs>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -139,6 +143,8 @@ export function getCreateListingInstructionDataEncoder(): Encoder<CreateListingI
],
["acceptedResolvers", getArrayEncoder(getAddressEncoder())],
["quantity", getU32Encoder()],
["name", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["description", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["metadataUri", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
]),
(value) => ({ ...value, discriminator: CREATE_LISTING_DISCRIMINATOR }),
@@ -158,6 +164,8 @@ export function getCreateListingInstructionDataDecoder(): Decoder<CreateListingI
],
["acceptedResolvers", getArrayDecoder(getAddressDecoder())],
["quantity", getU32Decoder()],
["name", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["description", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["metadataUri", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
]);
}
@@ -187,6 +195,8 @@ export type CreateListingAsyncInput<
altCurrencies: CreateListingInstructionDataArgs["altCurrencies"];
acceptedResolvers: CreateListingInstructionDataArgs["acceptedResolvers"];
quantity: CreateListingInstructionDataArgs["quantity"];
name: CreateListingInstructionDataArgs["name"];
description: CreateListingInstructionDataArgs["description"];
metadataUri: CreateListingInstructionDataArgs["metadataUri"];
};
@@ -279,6 +289,8 @@ export type CreateListingInput<
altCurrencies: CreateListingInstructionDataArgs["altCurrencies"];
acceptedResolvers: CreateListingInstructionDataArgs["acceptedResolvers"];
quantity: CreateListingInstructionDataArgs["quantity"];
name: CreateListingInstructionDataArgs["name"];
description: CreateListingInstructionDataArgs["description"];
metadataUri: CreateListingInstructionDataArgs["metadataUri"];
};

View File

@@ -14,6 +14,8 @@ import {
getAddressEncoder,
getBytesDecoder,
getBytesEncoder,
getOptionDecoder,
getOptionEncoder,
getProgramDerivedAddress,
getStructDecoder,
getStructEncoder,
@@ -33,6 +35,8 @@ import {
type Instruction,
type InstructionWithAccounts,
type InstructionWithData,
type Option,
type OptionOrNullable,
type ReadonlyAccount,
type ReadonlyUint8Array,
type TransactionSigner,
@@ -121,7 +125,7 @@ export type CreateOrderInstructionData = {
discriminator: ReadonlyUint8Array;
orderId: bigint;
escrowId: bigint;
resolver: Address;
resolver: Option<Address>;
paymentCurrency: SolistingStateCurrency;
expectedAmount: bigint;
maxSlippageBps: number;
@@ -130,7 +134,7 @@ export type CreateOrderInstructionData = {
export type CreateOrderInstructionDataArgs = {
orderId: number | bigint;
escrowId: number | bigint;
resolver: Address;
resolver: OptionOrNullable<Address>;
paymentCurrency: SolistingStateCurrencyArgs;
expectedAmount: number | bigint;
maxSlippageBps: number;
@@ -142,7 +146,7 @@ export function getCreateOrderInstructionDataEncoder(): Encoder<CreateOrderInstr
["discriminator", fixEncoderSize(getBytesEncoder(), 8)],
["orderId", getU64Encoder()],
["escrowId", getU64Encoder()],
["resolver", getAddressEncoder()],
["resolver", getOptionEncoder(getAddressEncoder())],
["paymentCurrency", getSolistingStateCurrencyEncoder()],
["expectedAmount", getU64Encoder()],
["maxSlippageBps", getU16Encoder()],
@@ -156,7 +160,7 @@ export function getCreateOrderInstructionDataDecoder(): Decoder<CreateOrderInstr
["discriminator", fixDecoderSize(getBytesDecoder(), 8)],
["orderId", getU64Decoder()],
["escrowId", getU64Decoder()],
["resolver", getAddressDecoder()],
["resolver", getOptionDecoder(getAddressDecoder())],
["paymentCurrency", getSolistingStateCurrencyDecoder()],
["expectedAmount", getU64Decoder()],
["maxSlippageBps", getU16Decoder()],

View File

@@ -101,6 +101,8 @@ export type UpdateListingInstructionData = {
altCurrencies: Array<SolistingStateAltCurrencyConfig>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -111,6 +113,8 @@ export type UpdateListingInstructionDataArgs = {
altCurrencies: Array<SolistingStateAltCurrencyConfigArgs>;
acceptedResolvers: Array<Address>;
quantity: number;
name: string;
description: string;
metadataUri: string;
};
@@ -127,6 +131,8 @@ export function getUpdateListingInstructionDataEncoder(): Encoder<UpdateListingI
],
["acceptedResolvers", getArrayEncoder(getAddressEncoder())],
["quantity", getU32Encoder()],
["name", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["description", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
["metadataUri", addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
]),
(value) => ({ ...value, discriminator: UPDATE_LISTING_DISCRIMINATOR }),
@@ -145,6 +151,8 @@ export function getUpdateListingInstructionDataDecoder(): Decoder<UpdateListingI
],
["acceptedResolvers", getArrayDecoder(getAddressDecoder())],
["quantity", getU32Decoder()],
["name", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["description", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
["metadataUri", addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
]);
}
@@ -171,6 +179,8 @@ export type UpdateListingInput<
altCurrencies: UpdateListingInstructionDataArgs["altCurrencies"];
acceptedResolvers: UpdateListingInstructionDataArgs["acceptedResolvers"];
quantity: UpdateListingInstructionDataArgs["quantity"];
name: UpdateListingInstructionDataArgs["name"];
description: UpdateListingInstructionDataArgs["description"];
metadataUri: UpdateListingInstructionDataArgs["metadataUri"];
};

View File

@@ -115,6 +115,14 @@
"name": "quantity",
"type": "u32"
},
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "metadata_uri",
"type": "string"
@@ -208,6 +216,14 @@
"name": "quantity",
"type": "u32"
},
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "metadata_uri",
"type": "string"
@@ -1337,6 +1353,14 @@
],
"type": "u32"
},
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "string"
},
{
"name": "metadata_uri",
"type": "string"

View File

@@ -7,7 +7,7 @@ export * from './listing'
// Order exports are imported but we exclude the re-exported deriveEscrowId to avoid duplication
// since it's already exported from pda.js
export type { OrderAccountWithPda } from './order'
export { fetchOrdersForListing, fetchOrdersByBuyer, fetchOrder } from './order'
export { fetchOrdersForListing, fetchOrdersByBuyer, fetchOrderByEscrow, fetchOrder } from './order'
// Re-export Address for consumers
export type { Address, Account } from '@solana/kit'

View File

@@ -1,11 +1,11 @@
import { address, type Account, type Address, type Rpc } from '@solana/kit'
import { type GetProgramAccountsApi } from '@solana/rpc-api'
import { type GetAccountInfoApi } from '@solana/rpc-api'
import type { Base64EncodedBytes } from '@solana/rpc-types'
import type { Base64EncodedBytes, Lamports } from '@solana/rpc-types'
import {
decodeSolistingStateOrderAccount,
fetchSolistingStateOrderAccount,
SOLISTING_STATE_ORDER_ACCOUNT_DISCRIMINATOR,
getSolistingStateOrderAccountDiscriminatorBytes,
type SolistingStateOrderAccount,
} from './generated/solisting/src/generated/index'
import { deriveEscrowId } from './pda'
@@ -16,22 +16,57 @@ export type OrderAccountWithPda = Account<SolistingStateOrderAccount>
type GpaRpc = Rpc<GetProgramAccountsApi>
type GetRpc = Rpc<GetAccountInfoApi>
async function fetchAllOrders(rpc: GpaRpc): Promise<OrderAccountWithPda[]> {
const disc = SOLISTING_STATE_ORDER_ACCOUNT_DISCRIMINATOR
const discBase64 = Buffer.from(disc).toString('base64') as Base64EncodedBytes
// OrderAccount byte layout (Sol-currency variant, the only one currently implemented):
// 0: discriminator (8)
// 8: listing (32)
// 40: buyer (32)
// 72: seller (32)
// 104: resolver Option<Pubkey> = 1 byte tag + 32 bytes = 33 bytes
// 137: paymentCurrency — Sol variant = 1 byte tag + 0 data = 1 byte total
// 138: amount (8)
// 146: escrowAccount (32)
const LISTING_OFFSET = 8n
const BUYER_OFFSET = 40n
// escrowAccount offset is only valid for Sol-currency orders.
// SPL orders (currently rejected by the program with SplNotImplemented) would sit at offset 179.
const ESCROW_ACCOUNT_OFFSET = 146n
type RawGpaResult = Array<{
pubkey: Address
account: {
executable: boolean
lamports: bigint
owner: Address
space: bigint
data: [string, 'base64']
}
}>
const DISC_BASE64 = Buffer.from(
getSolistingStateOrderAccountDiscriminatorBytes(),
).toString('base64') as Base64EncodedBytes
async function gpa(
rpc: GpaRpc,
addressFilter: { offset: bigint; addr: Address },
): Promise<OrderAccountWithPda[]> {
const results = await rpc
.getProgramAccounts(PROGRAM_ADDRESS, {
encoding: 'base64',
filters: [{ memcmp: { offset: 0n, bytes: discBase64, encoding: 'base64' } }],
filters: [
{ memcmp: { offset: 0n, bytes: DISC_BASE64, encoding: 'base64' } },
{ memcmp: { offset: addressFilter.offset, bytes: addressFilter.addr as never, encoding: 'base58' } },
],
})
.send()
return (results as Array<{ pubkey: Address; account: { executable: boolean; lamports: bigint; owner: Address; space: bigint; data: [string, 'base64'] } }>).map((r) => {
return (results as RawGpaResult).map((r) => {
const data = new Uint8Array(Buffer.from(r.account.data[0], 'base64'))
return decodeSolistingStateOrderAccount({
address: r.pubkey,
data,
executable: r.account.executable,
lamports: r.account.lamports as unknown as import('@solana/rpc-types').Lamports,
lamports: r.account.lamports as unknown as Lamports,
programAddress: r.account.owner,
space: r.account.space,
exists: true,
@@ -39,26 +74,20 @@ async function fetchAllOrders(rpc: GpaRpc): Promise<OrderAccountWithPda[]> {
})
}
export async function fetchOrdersForListing(
rpc: GpaRpc,
listingPk: Address,
): Promise<OrderAccountWithPda[]> {
const all = await fetchAllOrders(rpc)
return all.filter((o) => o.data.listing === listingPk)
export function fetchOrdersForListing(rpc: GpaRpc, listingPk: Address): Promise<OrderAccountWithPda[]> {
return gpa(rpc, { offset: LISTING_OFFSET, addr: listingPk })
}
export async function fetchOrdersByBuyer(
rpc: GpaRpc,
buyer: Address,
): Promise<OrderAccountWithPda[]> {
const all = await fetchAllOrders(rpc)
return all.filter((o) => o.data.buyer === buyer)
export function fetchOrdersByBuyer(rpc: GpaRpc, buyer: Address): Promise<OrderAccountWithPda[]> {
return gpa(rpc, { offset: BUYER_OFFSET, addr: buyer })
}
export async function fetchOrder(
rpc: GetRpc,
addr: Address,
): Promise<OrderAccountWithPda | null> {
export async function fetchOrderByEscrow(rpc: GpaRpc, escrowPda: Address): Promise<OrderAccountWithPda | null> {
const results = await gpa(rpc, { offset: ESCROW_ACCOUNT_OFFSET, addr: escrowPda })
return results[0] ?? null
}
export async function fetchOrder(rpc: GetRpc, addr: Address): Promise<OrderAccountWithPda | null> {
return fetchSolistingStateOrderAccount(rpc, addr).catch(() => null)
}

File diff suppressed because it is too large Load Diff