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 { useTx } from '@/hooks/useTx'
import { useListing } from '@/hooks/useListing' import { useListing } from '@/hooks/useListing'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { useToast } from '@/components/ui/Toast'
import { import {
getCreateListingInstructionAsync, getCreateListingInstructionAsync,
getUpdateListingInstruction, getUpdateListingInstruction,
} from '@solisting/sdk' } 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 { interface FormState {
canonicalKind: 'Sol' | 'Spl'
mint: string
decimals: string
price: string price: string
quantity: string quantity: string
oracle: string oracle: string
metadataUri: string metadataUri: string
alts: AltEntry[]
resolvers: string[]
} }
function blank(): FormState { function blank(): FormState {
return { price: '', quantity: '', oracle: '', metadataUri: '' } return {
canonicalKind: 'Sol',
mint: '',
decimals: '6',
price: '',
quantity: '',
oracle: '',
metadataUri: '',
alts: [],
resolvers: [],
}
} }
interface Props { interface Props {
editPk?: Address 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) { export function CreateListingForm({ editPk }: Props) {
const router = useRouter() const router = useRouter()
const { signer } = useKitTransactionSigner() const { signer } = useKitTransactionSigner()
const { account } = useWallet() const { account } = useWallet()
const sendTx = useTx() const sendTx = useTx()
const toast = useToast()
const { data: existing } = useListing(editPk ?? ('' as Address)) const { data: existing } = useListing(editPk ?? ('' as Address))
const [form, setForm] = useState<FormState>(blank()) const [form, setForm] = useState<FormState>(blank())
@@ -44,22 +122,119 @@ export function CreateListingForm({ editPk }: Props) {
const isUpdate = !!editPk const isUpdate = !!editPk
// Pre-fill from on-chain data on first load
if (!initialized && existing && isUpdate) { if (!initialized && existing && isUpdate) {
setInitialized(true) setInitialized(true)
const d = existing.data const d = existing.data
const cur = d.canonicalCurrency
const isSpl = cur.__kind === 'Spl'
setForm({ 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), quantity: String(d.quantity),
oracle: isSome(d.canonicalOracle) ? d.canonicalOracle.value : '', oracle: isSome(d.canonicalOracle) ? d.canonicalOracle.value : '',
metadataUri: d.metadataUri ?? '', 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>) => return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value })) 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) { async function submit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!signer || !account) { if (!signer || !account) {
@@ -69,41 +244,36 @@ export function CreateListingForm({ editPk }: Props) {
setError(null) setError(null)
setLoading(true) setLoading(true)
try { try {
const priceN = parseFloat(form.price) const price = buildPrice()
if (isNaN(priceN) || priceN <= 0) throw new Error('Invalid price')
const priceLamports = BigInt(Math.round(priceN * 1e9))
const quantityN = parseInt(form.quantity, 10) const quantityN = parseInt(form.quantity, 10)
if (isNaN(quantityN) || quantityN <= 0) throw new Error('Invalid quantity') 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) { if (isUpdate && editPk) {
const existingData = existing?.data
const ix = getUpdateListingInstruction({ const ix = getUpdateListingInstruction({
seller: signer, seller: signer,
listingAccount: editPk, listingAccount: editPk,
canonicalCurrency: existingData?.canonicalCurrency ?? { __kind: 'Sol' }, canonicalCurrency,
price: priceLamports, price,
canonicalOracle, canonicalOracle,
altCurrencies: existingData?.altCurrencies ?? [], altCurrencies,
acceptedResolvers: existingData?.acceptedResolvers ?? [], acceptedResolvers,
quantity: quantityN, quantity: quantityN,
metadataUri: form.metadataUri, metadataUri: form.metadataUri,
}) })
await sendTx( await sendTx([ix as never], [['listings'], ['listing', editPk]], 'update_listing')
[ix as never],
[['listings'], ['listing', editPk]],
'update_listing',
)
} else { } else {
const listingId = BigInt(Date.now())
const ix = await getCreateListingInstructionAsync({ const ix = await getCreateListingInstructionAsync({
seller: signer, seller: signer,
listingId, listingId: BigInt(Date.now()),
canonicalCurrency: { __kind: 'Sol' }, canonicalCurrency,
price: priceLamports, price,
canonicalOracle, canonicalOracle,
altCurrencies: [], altCurrencies,
acceptedResolvers: [], acceptedResolvers,
quantity: quantityN, quantity: quantityN,
metadataUri: form.metadataUri, metadataUri: form.metadataUri,
}) })
@@ -117,26 +287,12 @@ export function CreateListingForm({ editPk }: Props) {
} }
} }
const LABEL_STYLE = { const priceUnit =
display: 'block', form.canonicalKind === 'Sol'
fontSize: 12, ? 'SOL'
fontWeight: 600, : form.mint
color: 'var(--mut)', ? form.mint.slice(0, 6) + '…'
marginBottom: 8, : 'SPL'
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
return ( return (
<div style={{ maxWidth: 640, margin: '0 auto' }}> <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' }}> <h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, letterSpacing: '-.02em' }}>
{isUpdate ? 'Update Listing' : 'Create Listing'} {isUpdate ? 'Update Listing' : 'Create Listing'}
</h1> </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 {isUpdate
? `Editing listing ${editPk?.slice(0, 8)}` ? `Editing listing ${editPk?.slice(0, 8)}`
: 'New SOL-priced listing on the solisting program'} : 'New listing on the solisting program'}
</p> </p>
<form <form
@@ -178,19 +334,51 @@ export function CreateListingForm({ editPk }: Props) {
gap: 22, 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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div> <div>
<label style={LABEL_STYLE}>PRICE (SOL)</label> <label style={LABEL}>PRICE ({priceUnit})</label>
<input value={form.price} onChange={set('price')} placeholder="0.00" style={INPUT_STYLE} /> <input value={form.price} onChange={set('price')} placeholder="0.00" style={INPUT} />
</div> </div>
<div> <div>
<label style={LABEL_STYLE}>QUANTITY</label> <label style={LABEL}>QUANTITY</label>
<input value={form.quantity} onChange={set('quantity')} placeholder="0" style={INPUT_STYLE} /> <input
value={form.quantity}
onChange={set('quantity')}
placeholder="0"
style={INPUT}
/>
</div> </div>
</div> </div>
{/* CANONICAL ORACLE */}
<div> <div>
<label style={LABEL_STYLE}> <label style={LABEL}>
CANONICAL ORACLE{' '} CANONICAL ORACLE{' '}
<span style={{ fontWeight: 400, textTransform: 'none' }}>(optional · Pyth V2 feed)</span> <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional · Pyth V2 feed)</span>
</label> </label>
@@ -198,12 +386,119 @@ export function CreateListingForm({ editPk }: Props) {
value={form.oracle} value={form.oracle}
onChange={set('oracle')} onChange={set('oracle')}
placeholder="Pyth price feed pubkey — leave empty for $1.00 stablecoin" placeholder="Pyth price feed pubkey — leave empty for $1.00 stablecoin"
style={INPUT_STYLE} style={INPUT_MONO}
/> />
</div> </div>
{/* ALT CURRENCIES */}
<div> <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{' '} METADATA URI{' '}
<span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span> <span style={{ fontWeight: 400, textTransform: 'none' }}>(max 256 chars)</span>
</label> </label>
@@ -211,7 +506,7 @@ export function CreateListingForm({ editPk }: Props) {
value={form.metadataUri} value={form.metadataUri}
onChange={set('metadataUri')} onChange={set('metadataUri')}
placeholder="ipfs://… or https://…" placeholder="ipfs://… or https://…"
style={INPUT_STYLE} style={INPUT_MONO}
/> />
</div> </div>
@@ -229,14 +524,7 @@ export function CreateListingForm({ editPk }: Props) {
</div> </div>
)} )}
<div <div style={{ display: 'flex', gap: 10, borderTop: '1px solid var(--bdSoft)', paddingTop: 20 }}>
style={{
display: 'flex',
gap: 10,
borderTop: '1px solid var(--bdSoft)',
paddingTop: 20,
}}
>
<Button type="submit" disabled={loading} style={{ flex: 1, padding: 13, fontSize: 14 }}> <Button type="submit" disabled={loading} style={{ flex: 1, padding: 13, fontSize: 14 }}>
{loading ? 'Sending…' : isUpdate ? 'Update Listing' : 'Create Listing'} {loading ? 'Sending…' : isUpdate ? 'Update Listing' : 'Create Listing'}
</Button> </Button>