diff --git a/app/src/components/CreateEscrowForm.tsx b/app/src/components/CreateEscrowForm.tsx index 7edcac2..cfc20d8 100644 --- a/app/src/components/CreateEscrowForm.tsx +++ b/app/src/components/CreateEscrowForm.tsx @@ -14,11 +14,13 @@ import { getSignatureFromTransaction, address as kitAddress, } from '@solana/kit' -import { useKitTransactionSigner, useWallet, useCluster, useSolanaClient } from '@solana/connector/react' -import { getCreateEscrowInstructionAsync } from '@descro/sdk' +import { useKitTransactionSigner, useWallet, useSolanaClient } from '@solana/connector/react' +import { getCreateEscrowInstructionAsync, getBuyerCreateEscrowInstructionAsync, findResolverEntryPda } from '@descro/sdk' import { lamportsFromSol } from '@/util/lamports' import type { Address } from '@descro/sdk' +const SYSTEM_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address + function getNextEscrowId(walletAddress: string): bigint { const key = `descro_escrow_id_${walletAddress}` const stored = localStorage.getItem(key) @@ -28,6 +30,15 @@ function getNextEscrowId(walletAddress: string): bigint { return BigInt(next) } +function getNextBuyerEscrowId(buyerAddress: string, sellerAddress: string): bigint { + const key = `descro_buyer_escrow_id_${buyerAddress}_${sellerAddress}` + const stored = localStorage.getItem(key) + const current = stored ? parseInt(stored, 10) : 0 + const next = current + 1 + localStorage.setItem(key, next.toString()) + return BigInt(next) +} + interface CreateEscrowFormProps { onCreated?: () => void } @@ -37,7 +48,8 @@ export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) { const { account: address } = useWallet() const { client } = useSolanaClient() - const [buyer, setBuyer] = useState('') + const [mode, setMode] = useState<'seller' | 'buyer'>('seller') + const [counterparty, setCounterparty] = useState('') const [amountSol, setAmountSol] = useState('') const [resolver, setResolver] = useState('') const [loading, setLoading] = useState(false) @@ -54,11 +66,11 @@ export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) { return } - let buyerAddress: Address + let counterpartyAddress: Address try { - buyerAddress = kitAddress(buyer) + counterpartyAddress = kitAddress(counterparty) } catch { - setError('Invalid buyer public key.') + setError(`Invalid ${mode === 'seller' ? 'buyer' : 'seller'} public key.`) return } @@ -68,7 +80,6 @@ export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) { return } const amountLamports = lamportsFromSol(amountSolNum) - // Vault is a 0-byte PDA funded entirely by the deposit; must be rent-exempt (~890,880 lamports) if (amountLamports < 890_880n) { setError('Amount too small: minimum is ~0.00089 SOL (vault must be rent-exempt).') return @@ -87,15 +98,37 @@ export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) { setLoading(true) try { const { rpc, rpcSubscriptions } = client - const escrowId = getNextEscrowId(address) - const ix = await getCreateEscrowInstructionAsync({ - seller: signer, - buyer: buyerAddress, - amount: amountLamports, - disputeResolver: resolverAddress, - escrowId, - }) + let ix: unknown + let escrowIdLabel: bigint + + if (mode === 'seller') { + const escrowId = getNextEscrowId(address) + escrowIdLabel = escrowId + const resolverAcct = resolverAddress ?? SYSTEM_PROGRAM_ADDRESS + const [resolverEntryAcct] = resolverAddress + ? await findResolverEntryPda({ authority: resolverAddress }) + : [SYSTEM_PROGRAM_ADDRESS] + ix = await getCreateEscrowInstructionAsync({ + seller: signer, + buyer: counterpartyAddress, + resolver: resolverAcct, + resolverEntry: resolverEntryAcct, + amount: amountLamports, + disputeResolver: resolverAddress, + escrowId, + }) + } else { + const escrowId = getNextBuyerEscrowId(address, counterpartyAddress) + escrowIdLabel = escrowId + ix = await getBuyerCreateEscrowInstructionAsync({ + buyer: signer, + seller: counterpartyAddress, + amount: amountLamports, + disputeResolver: resolverAddress, + escrowId, + }) + } const { value: latestBlockhash } = await rpc.getLatestBlockhash().send() @@ -108,11 +141,11 @@ export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) { const signed = await signTransactionMessageWithSigners(txMsg) assertIsTransactionWithBlockhashLifetime(signed) - await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions })(signed, { commitment: 'confirmed' }) + await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions: rpcSubscriptions as never })(signed, { commitment: 'confirmed' }) const sig = getSignatureFromTransaction(signed) - setSuccess(`Escrow #${escrowId} created! Tx: ${sig.slice(0, 16)}…`) - setBuyer('') + setSuccess(`Escrow #${escrowIdLabel} created! Tx: ${sig.slice(0, 16)}…`) + setCounterparty('') setAmountSol('') setResolver('') onCreated?.() @@ -123,16 +156,46 @@ export function CreateEscrowForm({ onCreated }: CreateEscrowFormProps) { } } + const counterpartyLabel = mode === 'seller' ? 'Buyer Public Key' : 'Seller Public Key' + const buttonLabel = mode === 'seller' ? 'Create Escrow' : 'Create & Fund Escrow' + return ( Create Escrow + + + + + + {mode === 'buyer' && ( + + + Buyer flow: you create and fund the escrow immediately. The seller must confirm before the escrow becomes active. + + + )} +
- Buyer Public Key * + {counterpartyLabel} * : undefined} > - {loading ? 'Creating…' : 'Create Escrow'} + {loading ? 'Creating…' : buttonLabel}
diff --git a/app/src/components/EscrowDetail.tsx b/app/src/components/EscrowDetail.tsx index d73b773..59c7a99 100644 --- a/app/src/components/EscrowDetail.tsx +++ b/app/src/components/EscrowDetail.tsx @@ -18,13 +18,15 @@ import { useKitTransactionSigner, useWallet, useSolanaClient } from '@solana/con import { StatusBadge } from './StatusBadge' import { isAwaitingDeposit, + isAwaitingSellerConfirm, isActive, isDisputed, getDepositInstructionAsync, - getCancelInstruction, + getCancelInstructionAsync, getCompleteInstructionAsync, getDisputeInstruction, getResolveInstructionAsync, + getSellerConfirmInstruction, findResolverEntryPda, DESCRO_EXT_RESOLVERS_PROGRAM_ADDRESS, Winner, @@ -93,7 +95,22 @@ export function EscrowDetail({ item, onAction }: EscrowDetailProps) { runTx(() => getDepositInstructionAsync({ buyer: signer!, escrowAccount: pda })) const handleCancel = () => - runTx(async () => getCancelInstruction({ seller: signer!, escrowAccount: pda })) + runTx(() => getCancelInstructionAsync({ canceller: signer!, buyer: escrow.buyer, escrowAccount: pda })) + + const handleSellerConfirm = () => + runTx(async () => { + const SYSTEM_PROGRAM = '11111111111111111111111111111111' as Parameters[0]['resolver'] + const resolver = resolverAddr ?? SYSTEM_PROGRAM + const [resolverEntryAddr] = resolverAddr + ? await findResolverEntryPda({ authority: resolverAddr }) + : [SYSTEM_PROGRAM] + return getSellerConfirmInstruction({ + seller: signer!, + resolver, + escrowAccount: pda, + resolverEntry: resolverEntryAddr, + }) + }) const handleComplete = () => runTx(() => @@ -152,7 +169,17 @@ export function EscrowDetail({ item, onAction }: EscrowDetailProps) { Deposit )} - {isAwaitingDeposit(escrow.state) && isSeller && ( + {isAwaitingDeposit(escrow.state) && (isSeller || isBuyer) && ( + + )} + {isAwaitingSellerConfirm(escrow.state) && isSeller && ( + + )} + {isAwaitingSellerConfirm(escrow.state) && (isSeller || isBuyer) && ( diff --git a/app/src/components/ResolverPanel.tsx b/app/src/components/ResolverPanel.tsx index b0a5958..485664d 100644 --- a/app/src/components/ResolverPanel.tsx +++ b/app/src/components/ResolverPanel.tsx @@ -20,6 +20,7 @@ import { getRegisterResolverInstructionAsync, resolverTypeLabel, ResolverType, + AcceptancePolicy, } from '@descro/sdk' import type { ResolverEntryWithPda } from '@descro/sdk' @@ -49,6 +50,13 @@ export function ResolverPanel() { const [feeRecipient, setFeeRecipient] = useState('') const [metadataUri, setMetadataUri] = useState('') const [resolverTypeIdx, setResolverTypeIdx] = useState(0) + const [acceptancePolicyIdx, setAcceptancePolicyIdx] = useState(0) + + const ACCEPTANCE_POLICY_OPTIONS: { label: string; value: AcceptancePolicy }[] = [ + { label: 'Open (anyone can assign you)', value: AcceptancePolicy.Open }, + { label: 'SignatureGated (you must co-sign)', value: AcceptancePolicy.SignatureGated }, + { label: 'ProgramGated', value: AcceptancePolicy.ProgramGated }, + ] const [formLoading, setFormLoading] = useState(false) const [error, setError] = useState(null) @@ -110,6 +118,7 @@ export function ResolverPanel() { const ix = await getRegisterResolverInstructionAsync({ authority: signer, resolverType, + acceptancePolicy: ACCEPTANCE_POLICY_OPTIONS[acceptancePolicyIdx].value, name: name.trim(), description: description.trim(), feeBps: fee, @@ -217,6 +226,19 @@ export function ResolverPanel() {
+ + Acceptance Policy + + + Fee (BPS) diff --git a/app/src/components/StatusBadge.tsx b/app/src/components/StatusBadge.tsx index b66cea9..de031c4 100644 --- a/app/src/components/StatusBadge.tsx +++ b/app/src/components/StatusBadge.tsx @@ -7,6 +7,7 @@ import type { EscrowState } from '@descro/sdk' const STATE_COLORS: Record = { AwaitingDeposit: '$blue9', + AwaitingSellerConfirm: '$purple9', Active: '$green9', Disputed: '$orange9', Complete: '$gray9', diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 7036b72..a3b1157 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -24,8 +24,20 @@ export { } from "./generated/descro/src/generated/instructions/complete"; export { getCancelInstruction, + getCancelInstructionAsync, type CancelInput, + type CancelAsyncInput, } from "./generated/descro/src/generated/instructions/cancel"; +export { + getBuyerCreateEscrowInstructionAsync, + getBuyerCreateEscrowInstruction, + type BuyerCreateEscrowAsyncInput, + type BuyerCreateEscrowInput, +} from "./generated/descro/src/generated/instructions/buyerCreateEscrow"; +export { + getSellerConfirmInstruction, + type SellerConfirmInput, +} from "./generated/descro/src/generated/instructions/sellerConfirm"; export { getDisputeInstruction, type DisputeInput, @@ -43,4 +55,5 @@ export { type RegisterResolverInput, } from "./generated/descro_ext_resolvers/src/generated/instructions/registerResolver"; export { Winner } from "./generated/descro/src/generated/types/winner"; -export { ResolverType } from "./generated/descro_ext_resolvers/src/generated/types/resolverType"; \ No newline at end of file +export { ResolverType } from "./generated/descro_ext_resolvers/src/generated/types/resolverType"; +export { AcceptancePolicy } from "./generated/descro_ext_resolvers/src/generated/types/acceptancePolicy"; \ No newline at end of file diff --git a/sdk/src/types.ts b/sdk/src/types.ts index ae363f0..3029e2b 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -32,6 +32,9 @@ export type ResolverEntryWithPda = { pda: Address; account: ResolverEntry }; export function isAwaitingDeposit(state: EscrowState): boolean { return state === EscrowState.AwaitingDeposit; } +export function isAwaitingSellerConfirm(state: EscrowState): boolean { + return state === EscrowState.AwaitingSellerConfirm; +} export function isActive(state: EscrowState): boolean { return state === EscrowState.Active; } @@ -48,6 +51,7 @@ export function isCancelled(state: EscrowState): boolean { export function escrowStateLabel(state: EscrowState): string { switch (state) { case EscrowState.AwaitingDeposit: return "AwaitingDeposit"; + case EscrowState.AwaitingSellerConfirm: return "AwaitingSellerConfirm"; case EscrowState.Active: return "Active"; case EscrowState.Disputed: return "Disputed"; case EscrowState.Complete: return "Complete";