Skip to content

Commit 4db2f03

Browse files
committed
Merge branch 'solana-labs-main'
2 parents 6ebaf00 + 8415868 commit 4db2f03

33 files changed

+1152
-160
lines changed

components/Members/AddMember.tsx

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import {
2+
ArrowCircleDownIcon,
3+
ArrowCircleUpIcon,
4+
ArrowLeftIcon,
5+
} from '@heroicons/react/outline'
6+
import { ViewState } from './types'
7+
import useMembersListStore from 'stores/useMembersListStore'
8+
import { PublicKey, TransactionInstruction } from '@solana/web3.js'
9+
import useRealm from '@hooks/useRealm'
10+
import Input from '@components/inputs/Input'
11+
import Button, { SecondaryButton } from '@components/Button'
12+
import Textarea from '@components/inputs/Textarea'
13+
import VoteBySwitch from 'pages/dao/[symbol]/proposal/components/VoteBySwitch'
14+
import {
15+
getMintMinAmountAsDecimal,
16+
parseMintNaturalAmountFromDecimal,
17+
} from '@tools/sdk/units'
18+
import { precision } from '@utils/formatting'
19+
import useWalletStore from 'stores/useWalletStore'
20+
import { getMintSchema } from '@utils/validations'
21+
import { useEffect, useState } from 'react'
22+
import { MintForm, UiInstruction } from '@utils/uiTypes/proposalCreationTypes'
23+
import { validateInstruction } from '@utils/instructionTools'
24+
import useGovernanceAssets from '@hooks/useGovernanceAssets'
25+
import {
26+
ASSOCIATED_TOKEN_PROGRAM_ID,
27+
Token,
28+
TOKEN_PROGRAM_ID,
29+
} from '@solana/spl-token'
30+
import {
31+
getInstructionDataFromBase64,
32+
serializeInstructionToBase64,
33+
} from '@models/serialisation'
34+
import { getATA } from '@utils/ataTools'
35+
import { RpcContext } from '@models/core/api'
36+
import { Governance } from '@models/accounts'
37+
import { ParsedAccount } from '@models/core/accounts'
38+
import { useRouter } from 'next/router'
39+
import { createProposal } from 'actions/createProposal'
40+
import { notify } from '@utils/notifications'
41+
import useQueryContext from '@hooks/useQueryContext'
42+
43+
interface AddMemberForm extends MintForm {
44+
description: string
45+
title: string
46+
}
47+
48+
//Can add only council members for now
49+
const AddMember = () => {
50+
const router = useRouter()
51+
const connection = useWalletStore((s) => s.connection)
52+
const wallet = useWalletStore((s) => s.current)
53+
const { fmtUrlWithCluster } = useQueryContext()
54+
const { fetchRealmGovernance } = useWalletStore((s) => s.actions)
55+
const { symbol } = router.query
56+
const { setCurrentCompactView } = useMembersListStore()
57+
const { getMintWithGovernances } = useGovernanceAssets()
58+
const {
59+
realmInfo,
60+
canChooseWhoVote,
61+
councilMint,
62+
realm,
63+
ownVoterWeight,
64+
mint,
65+
} = useRealm()
66+
const programId: PublicKey | undefined = realmInfo?.programId
67+
const [form, setForm] = useState<AddMemberForm>({
68+
destinationAccount: '',
69+
amount: 1,
70+
mintAccount: undefined,
71+
programId: programId?.toString(),
72+
description: '',
73+
title: '',
74+
})
75+
const mintMinAmount = form.mintAccount
76+
? getMintMinAmountAsDecimal(councilMint!)
77+
: 1
78+
const currentPrecision = precision(mintMinAmount)
79+
const [voteByCouncil, setVoteByCouncil] = useState(false)
80+
const [showOptions, setShowOptions] = useState(false)
81+
const [isLoading, setIsLoading] = useState(false)
82+
const [formErrors, setFormErrors] = useState({})
83+
const proposalTitle = `Add council member ${form.destinationAccount}`
84+
const schema = getMintSchema({ form, connection })
85+
86+
const setAmount = (event) => {
87+
const value = event.target.value
88+
handleSetForm({
89+
value: value,
90+
propertyName: 'amount',
91+
})
92+
}
93+
const handleSetForm = ({ propertyName, value }) => {
94+
setFormErrors({})
95+
setForm({ ...form, [propertyName]: value })
96+
}
97+
const handleGoBackToMainView = async () => {
98+
setCurrentCompactView(ViewState.MainView)
99+
}
100+
const validateAmountOnBlur = () => {
101+
const value = form.amount
102+
103+
handleSetForm({
104+
value: parseFloat(
105+
Math.max(
106+
Number(mintMinAmount),
107+
Math.min(Number(Number.MAX_SAFE_INTEGER), Number(value))
108+
).toFixed(currentPrecision)
109+
),
110+
propertyName: 'amount',
111+
})
112+
}
113+
//TODO common getMintInstruction
114+
async function getInstruction(): Promise<UiInstruction> {
115+
const isValid = await validateInstruction({ schema, form, setFormErrors })
116+
let serializedInstruction = ''
117+
const prerequisiteInstructions: TransactionInstruction[] = []
118+
if (isValid && programId && form.mintAccount?.governance?.pubkey) {
119+
//this is the original owner
120+
const destinationAccount = new PublicKey(form.destinationAccount)
121+
const mintPK = form.mintAccount.governance.info.governedAccount
122+
const mintAmount = parseMintNaturalAmountFromDecimal(
123+
form.amount!,
124+
form.mintAccount.mintInfo?.decimals
125+
)
126+
//we find true receiver address if its wallet and we need to create ATA the ata address will be the receiver
127+
const { currentAddress: receiverAddress, needToCreateAta } = await getATA(
128+
connection,
129+
destinationAccount,
130+
mintPK,
131+
wallet!
132+
)
133+
//we push this createATA instruction to transactions to create right before creating proposal
134+
//we don't want to create ata only when instruction is serialized
135+
if (needToCreateAta) {
136+
prerequisiteInstructions.push(
137+
Token.createAssociatedTokenAccountInstruction(
138+
ASSOCIATED_TOKEN_PROGRAM_ID, // always ASSOCIATED_TOKEN_PROGRAM_ID
139+
TOKEN_PROGRAM_ID, // always TOKEN_PROGRAM_ID
140+
mintPK, // mint
141+
receiverAddress, // ata
142+
destinationAccount, // owner of token account
143+
wallet!.publicKey! // fee payer
144+
)
145+
)
146+
}
147+
const transferIx = Token.createMintToInstruction(
148+
TOKEN_PROGRAM_ID,
149+
form.mintAccount.governance.info.governedAccount,
150+
receiverAddress,
151+
form.mintAccount.governance!.pubkey,
152+
[],
153+
mintAmount
154+
)
155+
serializedInstruction = serializeInstructionToBase64(transferIx)
156+
}
157+
const obj: UiInstruction = {
158+
serializedInstruction,
159+
isValid,
160+
governance: form.mintAccount?.governance,
161+
prerequisiteInstructions: prerequisiteInstructions,
162+
}
163+
return obj
164+
}
165+
//TODO common handle propose
166+
const handlePropose = async () => {
167+
setIsLoading(true)
168+
const instruction: UiInstruction = await getInstruction()
169+
if (instruction.isValid) {
170+
const governance = form.mintAccount?.governance
171+
let proposalAddress: PublicKey | null = null
172+
if (!realm) {
173+
setIsLoading(false)
174+
throw 'No realm selected'
175+
}
176+
177+
const rpcContext = new RpcContext(
178+
new PublicKey(realm.account.owner.toString()),
179+
realmInfo?.programVersion,
180+
wallet,
181+
connection.current,
182+
connection.endpoint
183+
)
184+
const instructionData = {
185+
data: instruction.serializedInstruction
186+
? getInstructionDataFromBase64(instruction.serializedInstruction)
187+
: null,
188+
holdUpTime: governance?.info?.config.minInstructionHoldUpTime,
189+
prerequisiteInstructions: instruction.prerequisiteInstructions || [],
190+
}
191+
try {
192+
// Fetch governance to get up to date proposalCount
193+
const selectedGovernance = (await fetchRealmGovernance(
194+
governance?.pubkey
195+
)) as ParsedAccount<Governance>
196+
197+
const ownTokenRecord = ownVoterWeight.getTokenRecordToCreateProposal(
198+
governance!.info.config
199+
)
200+
201+
const defaultProposalMint = !mint?.supply.isZero()
202+
? realm.info.communityMint
203+
: !councilMint?.supply.isZero()
204+
? realm.info.config.councilMint
205+
: undefined
206+
207+
const proposalMint =
208+
canChooseWhoVote && voteByCouncil
209+
? realm.info.config.councilMint
210+
: defaultProposalMint
211+
212+
if (!proposalMint) {
213+
throw new Error(
214+
'There is no suitable governing token for the proposal'
215+
)
216+
}
217+
//Description same as title
218+
proposalAddress = await createProposal(
219+
rpcContext,
220+
realm.pubkey,
221+
selectedGovernance.pubkey,
222+
ownTokenRecord.pubkey,
223+
form.title ? form.title : proposalTitle,
224+
form.description ? form.description : '',
225+
proposalMint,
226+
selectedGovernance?.info?.proposalCount,
227+
[instructionData],
228+
false
229+
)
230+
const url = fmtUrlWithCluster(
231+
`/dao/${symbol}/proposal/${proposalAddress}`
232+
)
233+
router.push(url)
234+
} catch (ex) {
235+
notify({ type: 'error', message: `${ex}` })
236+
}
237+
}
238+
setIsLoading(false)
239+
}
240+
241+
useEffect(() => {
242+
async function getMintWithGovernancesFcn() {
243+
const resp = await getMintWithGovernances()
244+
handleSetForm({
245+
value: resp.find(
246+
(x) =>
247+
x.governance?.info.governedAccount.toBase58() ===
248+
realm?.info.config.councilMint?.toBase58()
249+
),
250+
propertyName: 'mintAccount',
251+
})
252+
}
253+
getMintWithGovernancesFcn()
254+
}, [])
255+
return (
256+
<>
257+
<h3 className="mb-4 flex items-center hover:cursor-pointer">
258+
<>
259+
<ArrowLeftIcon
260+
onClick={handleGoBackToMainView}
261+
className="h-4 w-4 mr-1 text-primary-light mr-2"
262+
/>
263+
Add new member
264+
</>
265+
</h3>
266+
<div className="space-y-4">
267+
<Input
268+
label="Member's wallet"
269+
value={form.destinationAccount}
270+
type="text"
271+
onChange={(evt) =>
272+
handleSetForm({
273+
value: evt.target.value,
274+
propertyName: 'destinationAccount',
275+
})
276+
}
277+
noMaxWidth={true}
278+
error={formErrors['destinationAccount']}
279+
/>
280+
<div
281+
className={'flex items-center hover:cursor-pointer w-24 mt-3'}
282+
onClick={() => setShowOptions(!showOptions)}
283+
>
284+
{showOptions ? (
285+
<ArrowCircleUpIcon className="h-4 w-4 mr-1 text-primary-light" />
286+
) : (
287+
<ArrowCircleDownIcon className="h-4 w-4 mr-1 text-primary-light" />
288+
)}
289+
<small className="text-fgd-3">Options</small>
290+
</div>
291+
{showOptions && (
292+
<>
293+
<Input
294+
noMaxWidth={true}
295+
label="Proposal Title"
296+
placeholder={
297+
form.amount && form.destinationAccount
298+
? proposalTitle
299+
: 'Title of your proposal'
300+
}
301+
value={form.title}
302+
type="text"
303+
onChange={(evt) =>
304+
handleSetForm({
305+
value: evt.target.value,
306+
propertyName: 'title',
307+
})
308+
}
309+
/>
310+
<Textarea
311+
noMaxWidth={true}
312+
label="Proposal Description"
313+
placeholder={
314+
'Description of your proposal or use a github gist link (optional)'
315+
}
316+
wrapperClassName="mb-5"
317+
value={form.description}
318+
onChange={(evt) =>
319+
handleSetForm({
320+
value: evt.target.value,
321+
propertyName: 'description',
322+
})
323+
}
324+
></Textarea>
325+
<Input
326+
min={mintMinAmount}
327+
label="Voter weight"
328+
value={form.amount}
329+
type="number"
330+
onChange={setAmount}
331+
step={mintMinAmount}
332+
error={formErrors['amount']}
333+
onBlur={validateAmountOnBlur}
334+
/>
335+
{canChooseWhoVote && (
336+
<VoteBySwitch
337+
checked={voteByCouncil}
338+
onChange={() => {
339+
setVoteByCouncil(!voteByCouncil)
340+
}}
341+
></VoteBySwitch>
342+
)}
343+
</>
344+
)}
345+
</div>
346+
<div className="flex flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0 mt-4">
347+
<SecondaryButton
348+
disabled={isLoading}
349+
className="sm:w-1/2 text-th-fgd-1"
350+
onClick={handleGoBackToMainView}
351+
>
352+
Cancel
353+
</SecondaryButton>
354+
<Button
355+
className="sm:w-1/2"
356+
onClick={handlePropose}
357+
isLoading={isLoading}
358+
>
359+
<div>Propose</div>
360+
</Button>
361+
</div>
362+
</>
363+
)
364+
}
365+
366+
export default AddMember

0 commit comments

Comments
 (0)