resolver picker

This commit is contained in:
thesn10
2026-06-27 14:52:11 +02:00
parent 0c86751d51
commit e799c0be9d
10 changed files with 155 additions and 41 deletions

View File

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

View File

@@ -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),
match resolver {
Some(r) => require!(
listing.accepted_resolvers.contains(&r),
SolistingError::ResolverNotAccepted
);
),
None => return err!(SolistingError::ResolverNotAccepted),
}
}
let amount = if payment_currency == listing.canonical_currency {
@@ -143,7 +146,7 @@ pub fn handler(
},
),
amount,
Some(resolver),
resolver,
escrow_id,
)?;

View File

@@ -70,7 +70,7 @@ pub mod solisting {
ctx: Context<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
resolver: Option<Pubkey>,
payment_currency: state::Currency,
expected_amount: u64,
max_slippage_bps: u16,

View File

@@ -69,7 +69,7 @@ pub struct OrderAccount {
pub listing: Pubkey,
pub buyer: Pubkey,
pub seller: Pubkey,
pub resolver: Pubkey,
pub resolver: Option<Pubkey>,
/// The currency the buyer chose to pay in.
pub payment_currency: Currency,
/// Actual amount paid (may differ from price when oracle-converted).

View File

@@ -195,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,
@@ -205,7 +204,7 @@ pub fn ix_create_order(
max_slippage_bps,
canonical_oracle,
target_oracle,
resolver,
Some(Pubkey::new_unique()),
)
}
@@ -219,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);
@@ -264,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);
@@ -276,7 +275,7 @@ pub fn ix_accept_order(
&solisting::instruction::AcceptOrder {}.data(),
solisting::accounts::AcceptOrder {
seller: *seller,
resolver,
resolver: resolver.unwrap_or(system_program::ID),
listing_account,
order_account,
escrow_account,

View File

@@ -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],
);
@@ -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);

View File

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

View File

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

View File

@@ -21,15 +21,15 @@ type GetRpc = Rpc<GetAccountInfoApi>
// 8: listing (32)
// 40: buyer (32)
// 72: seller (32)
// 104: resolver (32)
// 136: paymentCurrency — Sol variant = 1 byte tag + 0 data = 1 byte total
// 137: amount (8)
// 145: escrowAccount (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 178.
const ESCROW_ACCOUNT_OFFSET = 145n
// SPL orders (currently rejected by the program with SplNotImplemented) would sit at offset 179.
const ESCROW_ACCOUNT_OFFSET = 146n
type RawGpaResult = Array<{
pubkey: Address

View File

@@ -835,7 +835,9 @@
},
{
"name": "resolver",
"type": "pubkey"
"type": {
"option": "pubkey"
}
},
{
"name": "payment_currency",
@@ -1461,7 +1463,9 @@
},
{
"name": "resolver",
"type": "pubkey"
"type": {
"option": "pubkey"
}
},
{
"name": "payment_currency",