Compare commits
11 Commits
b18bdefcc0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3097fdc8a | ||
|
|
aafbc17e94 | ||
|
|
aac025e2a8 | ||
|
|
e799c0be9d | ||
|
|
0c86751d51 | ||
|
|
02c26e186f | ||
|
|
12f1344138 | ||
|
|
94d2d67e83 | ||
|
|
ec1c7d9f8c | ||
|
|
555598535d | ||
|
|
ca1c0b1cd8 |
@@ -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
120
README.md
Normal 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.
|
||||
8
app/src/app/escrow/[pk]/page.tsx
Normal file
8
app/src/app/escrow/[pk]/page.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
110
app/src/components/EscrowDetail.tsx
Normal file
110
app/src/components/EscrowDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
app/src/hooks/useEscrow.ts
Normal file
21
app/src/hooks/useEscrow.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
18
app/src/hooks/useOrderByEscrow.ts
Normal file
18
app/src/hooks/useOrderByEscrow.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user