feat: add canonical currency, alt currencies and accepted resolvers to create listing form

This commit is contained in:
thesn10
2026-06-25 19:43:22 +02:00
parent 5c9b427fce
commit b18bdefcc0

View File

@@ -7,34 +7,112 @@ import { isSome } from '@solana/kit'
import { useTx } from '@/hooks/useTx'
import { useListing } from '@/hooks/useListing'
import { Button } from '@/components/ui/Button'
import { useToast } from '@/components/ui/Toast'
import {
getCreateListingInstructionAsync,
getUpdateListingInstruction,
} from '@solisting/sdk'
import type { Address } from '@solisting/sdk'
import type { Address, SolistingStateAltCurrencyConfigArgs } from '@solisting/sdk'
interface AltEntry {
currency: string
oracle: string
decimals: string
}
interface FormState {
canonicalKind: 'Sol' | 'Spl'
mint: string
decimals: string
price: string
quantity: string
oracle: string
metadataUri: string
alts: AltEntry[]
resolvers: string[]
}
function blank(): FormState {
return { price: '', quantity: '', oracle: '', metadataUri: '' }
return {
canonicalKind: 'Sol',
mint: '',
decimals: '6',
price: '',
quantity: '',
oracle: '',
metadataUri: '',
alts: [],
resolvers: [],
}
}
interface Props {
editPk?: Address
}
const LABEL: React.CSSProperties = {
display: 'block',
fontSize: 12,
fontWeight: 600,
color: 'var(--mut)',
marginBottom: 8,
letterSpacing: '.04em',
}
const INPUT: React.CSSProperties = {
width: '100%',
height: 42,
padding: '0 13px',
background: 'var(--bg3)',
border: '1px solid var(--bd)',
borderRadius: 10,
fontSize: 13,
outline: 'none',
color: 'inherit',
boxSizing: 'border-box',
}
const INPUT_MONO: React.CSSProperties = { ...INPUT, fontFamily: 'var(--font-mono)' }
const SMALL_INPUT: React.CSSProperties = {
...INPUT,
height: 40,
borderRadius: 9,
fontSize: 12.5,
}
const SMALL_MONO: React.CSSProperties = { ...SMALL_INPUT, fontFamily: 'var(--font-mono)' }
function CurrencyToggle({
value,
onChange,
}: {
value: 'Sol' | 'Spl'
onChange: (v: 'Sol' | 'Spl') => void
}) {
const active = (on: boolean): React.CSSProperties => ({
flex: 1,
padding: 11,
borderRadius: 10,
fontSize: 13,
fontWeight: 600,
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)'}`,
})
return (
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => onChange('Sol')} style={active(value === 'Sol')}>
SOL
</button>
<button type="button" onClick={() => onChange('Spl')} style={active(value === 'Spl')}>
SPL Token
</button>
</div>
)
}
export function CreateListingForm({ editPk }: Props) {
const router = useRouter()
const { signer } = useKitTransactionSigner()
const { account } = useWallet()
const sendTx = useTx()
const toast = useToast()
const { data: existing } = useListing(editPk ?? ('' as Address))
const [form, setForm] = useState<FormState>(blank())
@@ -44,22 +122,119 @@ export function CreateListingForm({ editPk }: Props) {
const isUpdate = !!editPk
// Pre-fill from on-chain data on first load
if (!initialized && existing && isUpdate) {
setInitialized(true)
const d = existing.data
const cur = d.canonicalCurrency
const isSpl = cur.__kind === 'Spl'
setForm({
price: (Number(d.price) / 1e9).toString(),
canonicalKind: cur.__kind,
mint: isSpl ? (cur as { __kind: 'Spl'; mint: Address; decimals: number }).mint : '',
decimals: isSpl
? String((cur as { __kind: 'Spl'; mint: Address; decimals: number }).decimals)
: '6',
price: isSpl
? String(
Number(d.price) /
Math.pow(
10,
(cur as { __kind: 'Spl'; mint: Address; decimals: number }).decimals,
),
)
: (Number(d.price) / 1e9).toString(),
quantity: String(d.quantity),
oracle: isSome(d.canonicalOracle) ? d.canonicalOracle.value : '',
metadataUri: d.metadataUri ?? '',
alts: d.altCurrencies.map((a) => ({
currency:
a.currency.__kind === 'Sol'
? 'SOL'
: (a.currency as { __kind: 'Spl'; mint: Address; decimals: number }).mint,
oracle: isSome(a.usdOracle) ? a.usdOracle.value : '',
decimals:
a.currency.__kind === 'Spl'
? String(
(a.currency as { __kind: 'Spl'; mint: Address; decimals: number }).decimals,
)
: '6',
})),
resolvers: [...d.acceptedResolvers],
})
}
function set(key: keyof FormState) {
function set<K extends keyof FormState>(key: K) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }))
}
// Alt currency helpers
function addAlt() {
if (form.alts.length >= 3) return
setForm((f) => ({ ...f, alts: [...f.alts, { currency: '', oracle: '', decimals: '6' }] }))
}
function removeAlt(i: number) {
setForm((f) => ({ ...f, alts: f.alts.filter((_, idx) => idx !== i) }))
}
function setAlt(i: number, key: keyof AltEntry, val: string) {
setForm((f) => {
const alts = f.alts.map((a, idx) => (idx === i ? { ...a, [key]: val } : a))
return { ...f, alts }
})
}
// Resolver helpers
function addResolver() {
if (form.resolvers.length >= 4) return
setForm((f) => ({ ...f, resolvers: [...f.resolvers, ''] }))
}
function removeResolver(i: number) {
setForm((f) => ({ ...f, resolvers: f.resolvers.filter((_, idx) => idx !== i) }))
}
function setResolver(i: number, val: string) {
setForm((f) => {
const resolvers = f.resolvers.map((r, idx) => (idx === i ? val : r))
return { ...f, resolvers }
})
}
function buildCurrency() {
if (form.canonicalKind === 'Sol') return { __kind: 'Sol' as const }
const dec = parseInt(form.decimals, 10)
return { __kind: 'Spl' as const, mint: form.mint as Address, decimals: isNaN(dec) ? 6 : dec }
}
function buildPrice() {
const n = parseFloat(form.price)
if (isNaN(n) || n <= 0) throw new Error('Invalid price')
if (form.canonicalKind === 'Sol') return BigInt(Math.round(n * 1e9))
const dec = parseInt(form.decimals, 10)
return BigInt(Math.round(n * Math.pow(10, isNaN(dec) ? 6 : dec)))
}
function buildAlts(): SolistingStateAltCurrencyConfigArgs[] {
return form.alts
.filter((a) => a.currency.trim())
.map((a) => {
const isSol = a.currency.trim().toUpperCase() === 'SOL'
const dec = parseInt(a.decimals, 10)
return {
currency: isSol
? { __kind: 'Sol' as const }
: {
__kind: 'Spl' as const,
mint: a.currency.trim() as Address,
decimals: isNaN(dec) ? 6 : dec,
},
usdOracle: a.oracle.trim() ? (a.oracle.trim() as Address) : null,
}
})
}
function buildResolvers(): Address[] {
return form.resolvers.filter((r) => r.trim().length > 30).map((r) => r.trim() as Address)
}
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!signer || !account) {
@@ -69,41 +244,36 @@ export function CreateListingForm({ editPk }: Props) {
setError(null)
setLoading(true)
try {
const priceN = parseFloat(form.price)
if (isNaN(priceN) || priceN <= 0) throw new Error('Invalid price')
const priceLamports = BigInt(Math.round(priceN * 1e9))
const price = buildPrice()
const quantityN = parseInt(form.quantity, 10)
if (isNaN(quantityN) || quantityN <= 0) throw new Error('Invalid quantity')
const canonicalOracle = form.oracle ? (form.oracle as Address) : null
const canonicalOracle = form.oracle.trim() ? (form.oracle.trim() as Address) : null
const canonicalCurrency = buildCurrency()
const altCurrencies = buildAlts()
const acceptedResolvers = buildResolvers()
if (isUpdate && editPk) {
const existingData = existing?.data
const ix = getUpdateListingInstruction({
seller: signer,
listingAccount: editPk,
canonicalCurrency: existingData?.canonicalCurrency ?? { __kind: 'Sol' },
price: priceLamports,
canonicalCurrency,
price,
canonicalOracle,
altCurrencies: existingData?.altCurrencies ?? [],
acceptedResolvers: existingData?.acceptedResolvers ?? [],
altCurrencies,
acceptedResolvers,
quantity: quantityN,
metadataUri: form.metadataUri,
})
await sendTx(
[ix as never],
[['listings'], ['listing', editPk]],
'update_listing',
)
await sendTx([ix as never], [['listings'], ['listing', editPk]], 'update_listing')
} else {
const listingId = BigInt(Date.now())
const ix = await getCreateListingInstructionAsync({
seller: signer,
listingId,
canonicalCurrency: { __kind: 'Sol' },
price: priceLamports,
listingId: BigInt(Date.now()),
canonicalCurrency,
price,
canonicalOracle,
altCurrencies: [],
acceptedResolvers: [],
altCurrencies,
acceptedResolvers,
quantity: quantityN,
metadataUri: form.metadataUri,
})
@@ -117,26 +287,12 @@ export function CreateListingForm({ editPk }: Props) {
}
}
const LABEL_STYLE = {
display: 'block',
fontSize: 12,
fontWeight: 600,
color: 'var(--mut)',
marginBottom: 8,
letterSpacing: '.04em',
} as const
const INPUT_STYLE = {
width: '100%',
height: 42,
padding: '0 13px',
background: 'var(--bg3)',
border: '1px solid var(--bd)',
borderRadius: 10,
fontSize: 13,
outline: 'none',
color: 'inherit',
boxSizing: 'border-box' as const,
} as const
const priceUnit =
form.canonicalKind === 'Sol'
? 'SOL'
: form.mint
? form.mint.slice(0, 6) + '…'
: 'SPL'
return (
<div style={{ maxWidth: 640, margin: '0 auto' }}>
@@ -160,10 +316,10 @@ export function CreateListingForm({ editPk }: Props) {
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
{isUpdate ? 'Update Listing' : 'Create Listing'}
</h1>
<p style={{ margin: '6px 0 26px', fontSize: 13, color: 'var(--mut)' }}>
<p style={{ margin: '6px 0 26px', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--mut)' }}>
{isUpdate
? `Editing listing ${editPk?.slice(0, 8)}`
: 'New SOL-priced listing on the solisting program'}
: 'New listing on the solisting program'}
</p>
<form
@@ -178,19 +334,51 @@ export function CreateListingForm({ editPk }: Props) {
gap: 22,
}}
>
{/* CANONICAL CURRENCY */}
<div>
<label style={LABEL}>CANONICAL CURRENCY</label>
<CurrencyToggle
value={form.canonicalKind}
onChange={(v) => setForm((f) => ({ ...f, canonicalKind: v }))}
/>
{form.canonicalKind === 'Spl' && (
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
<input
value={form.mint}
onChange={set('mint')}
placeholder="Token mint address"
style={{ ...INPUT_MONO, flex: 2 }}
/>
<input
value={form.decimals}
onChange={set('decimals')}
placeholder="decimals"
style={{ ...INPUT, flex: 1 }}
/>
</div>
)}
</div>
{/* PRICE + QUANTITY */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<label style={LABEL_STYLE}>PRICE (SOL)</label>
<input value={form.price} onChange={set('price')} placeholder="0.00" style={INPUT_STYLE} />
<label style={LABEL}>PRICE ({priceUnit})</label>
<input value={form.price} onChange={set('price')} placeholder="0.00" style={INPUT} />
</div>
<div>
<label style={LABEL_STYLE}>QUANTITY</label>
<input value={form.quantity} onChange={set('quantity')} placeholder="0" style={INPUT_STYLE} />
<label style={LABEL}>QUANTITY</label>
<input
value={form.quantity}
onChange={set('quantity')}
placeholder="0"
style={INPUT}
/>
</div>
</div>
{/* CANONICAL ORACLE */}
<div>
<label style={LABEL_STYLE}>
<label style={LABEL}>
CANONICAL ORACLE{' '}
<span style={{ fontWeight: 400, textTransform: 'none' }}>(optional · Pyth V2 feed)</span>
</label>
@@ -198,12 +386,119 @@ export function CreateListingForm({ editPk }: Props) {
value={form.oracle}
onChange={set('oracle')}
placeholder="Pyth price feed pubkey — leave empty for $1.00 stablecoin"
style={INPUT_STYLE}
style={INPUT_MONO}
/>
</div>
{/* ALT CURRENCIES */}
<div>
<label style={LABEL_STYLE}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<label style={{ ...LABEL, marginBottom: 0 }}>
ALT CURRENCIES{' '}
<span style={{ fontWeight: 400, textTransform: 'none' }}>(max 3)</span>
</label>
{form.alts.length < 3 && (
<button
type="button"
onClick={addAlt}
style={{ fontSize: 12, fontWeight: 600, color: 'var(--acc2light)', background: 'none', border: 'none', cursor: 'pointer' }}
>
+ Add
</button>
)}
</div>
{form.alts.map((alt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<input
value={alt.currency}
onChange={(e) => setAlt(i, 'currency', e.target.value)}
placeholder="SOL or mint"
style={{ ...SMALL_MONO, flex: 1 }}
/>
{alt.currency.trim() && alt.currency.trim().toUpperCase() !== 'SOL' && (
<input
value={alt.decimals}
onChange={(e) => setAlt(i, 'decimals', e.target.value)}
placeholder="dec"
style={{ ...SMALL_INPUT, width: 58, flex: 'none' }}
/>
)}
<input
value={alt.oracle}
onChange={(e) => setAlt(i, 'oracle', e.target.value)}
placeholder="USD oracle (optional)"
style={{ ...SMALL_MONO, flex: 2 }}
/>
<button
type="button"
onClick={() => removeAlt(i)}
style={{
width: 40,
height: 40,
borderRadius: 9,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
color: 'var(--danger)',
fontSize: 18,
flexShrink: 0,
cursor: 'pointer',
}}
>
×
</button>
</div>
))}
</div>
{/* ACCEPTED RESOLVERS */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<label style={{ ...LABEL, marginBottom: 0 }}>
ACCEPTED RESOLVERS{' '}
<span style={{ fontWeight: 400, textTransform: 'none' }}>(max 4 · empty = any)</span>
</label>
{form.resolvers.length < 4 && (
<button
type="button"
onClick={addResolver}
style={{ fontSize: 12, fontWeight: 600, color: 'var(--acc2light)', background: 'none', border: 'none', cursor: 'pointer' }}
>
+ Add
</button>
)}
</div>
{form.resolvers.map((rv, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<input
value={rv}
onChange={(e) => setResolver(i, e.target.value)}
placeholder="Resolver pubkey"
style={{ ...SMALL_MONO, flex: 1 }}
/>
<button
type="button"
onClick={() => removeResolver(i)}
style={{
width: 40,
height: 40,
borderRadius: 9,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
color: 'var(--danger)',
fontSize: 18,
flexShrink: 0,
cursor: 'pointer',
}}
>
×
</button>
</div>
))}
</div>
{/* METADATA URI */}
<div>
<label style={LABEL}>
METADATA URI{' '}
<span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span>
</label>
@@ -211,7 +506,7 @@ export function CreateListingForm({ editPk }: Props) {
value={form.metadataUri}
onChange={set('metadataUri')}
placeholder="ipfs://… or https://…"
style={INPUT_STYLE}
style={INPUT_MONO}
/>
</div>
@@ -229,14 +524,7 @@ export function CreateListingForm({ editPk }: Props) {
</div>
)}
<div
style={{
display: 'flex',
gap: 10,
borderTop: '1px solid var(--bdSoft)',
paddingTop: 20,
}}
>
<div style={{ display: 'flex', gap: 10, borderTop: '1px solid var(--bdSoft)', paddingTop: 20 }}>
<Button type="submit" disabled={loading} style={{ flex: 1, padding: 13, fontSize: 14 }}>
{loading ? 'Sending…' : isUpdate ? 'Update Listing' : 'Create Listing'}
</Button>