diff --git a/components/instructions/programs/nameService.tsx b/components/instructions/programs/nameService.tsx new file mode 100644 index 0000000000..41bdee140e --- /dev/null +++ b/components/instructions/programs/nameService.tsx @@ -0,0 +1,24 @@ +import { NAME_PROGRAM_ID } from '@bonfida/spl-name-service' +import { AccountMetaData } from '@solana/spl-governance' +import { Connection, PublicKey } from '@solana/web3.js' + +export const NAME_SERVICE_INSTRUCTIONS = { + [NAME_PROGRAM_ID.toBase58()]: { + 2: { + name: 'Domain Name Service: Transfer Domain Name', + accounts: [{ name: 'Domain Name Address' }, { name: 'Treasury Account' }], + getDataUI: async ( + _connection: Connection, + data: Uint8Array, + _accounts: AccountMetaData[] + ) => { + const decodedData = new PublicKey(data.slice(1)) + return ( + <> + New Owner: {decodedData.toBase58()} + > + ) + }, + }, + }, +} diff --git a/components/instructions/programs/names.ts b/components/instructions/programs/names.ts index 91e80466d0..f168946254 100644 --- a/components/instructions/programs/names.ts +++ b/components/instructions/programs/names.ts @@ -4,6 +4,7 @@ import { LIDO_PROGRAM_ID, LIDO_PROGRAM_ID_DEVNET, } from '@components/TreasuryAccount/ConvertToStSol' +import { NAME_PROGRAM_ID } from '@bonfida/spl-name-service' export const GOVERNANCE_PROGRAM_NAMES = { GqTPL6qRf5aUuqscLh8Rg2HTxPUXfhhAXDptTLhp1t2J: 'Mango Governance Program', @@ -43,6 +44,7 @@ export const PROGRAM_NAMES = { VotEn9AWwTFtJPJSMV5F9jsMY6QwWM5qn3XP9PATGW7: 'PsyDO Voter Stake Registry Program', [foresightConsts.PROGRAM_ID]: 'Foresight Dex', + [NAME_PROGRAM_ID.toBase58()]: 'Solana Name Service Program', AwyKDr1Z5BfdvK3jX1UWopyjsJSV5cq4cuJpoYLofyEn: 'Validator Dao', Stake11111111111111111111111111111111111111: 'Stake Program', StakeConfig11111111111111111111111111111111: 'Stake Config', diff --git a/components/instructions/tools.tsx b/components/instructions/tools.tsx index b00ee0552b..ed9fb00656 100644 --- a/components/instructions/tools.tsx +++ b/components/instructions/tools.tsx @@ -26,8 +26,10 @@ import { PROGRAM_IDS } from '@castlefinance/vault-sdk' import { FORESIGHT_INSTRUCTIONS } from './programs/foresight' import { SAGA_PHONE } from './programs/SagaPhone' import { LIDO_INSTRUCTIONS } from './programs/lido' +import { NAME_SERVICE_INSTRUCTIONS } from './programs/nameService' import { TOKEN_AUCTION_INSTRUCTIONS } from './programs/tokenAuction' import { VALIDATORDAO_INSTRUCTIONS } from './programs/validatordao' + /** * Default governance program id instance */ @@ -258,6 +260,7 @@ export const INSTRUCTION_DESCRIPTORS = { ...VOTE_STAKE_REGISTRY_INSTRUCTIONS, ...NFT_VOTER_INSTRUCTIONS, ...STREAMFLOW_INSTRUCTIONS, + ...NAME_SERVICE_INSTRUCTIONS, ...SAGA_PHONE, ...TOKEN_AUCTION_INSTRUCTIONS, ...VALIDATORDAO_INSTRUCTIONS, diff --git a/hooks/useDomainNames.tsx b/hooks/useDomainNames.tsx new file mode 100644 index 0000000000..023200a675 --- /dev/null +++ b/hooks/useDomainNames.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react' + +import { + getAllDomains, + performReverseLookupBatch, +} from '@bonfida/spl-name-service' + +interface Domains { + domainName: string | undefined + domainAddress: string +} + +const useDomainsForAccount = (connection, governedAccount) => { + const [accountDomains, setAccountDomains] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(async () => { + setIsLoading(true) + const domains = await getAllDomains(connection, governedAccount.pubkey) + + if (domains.length > 0) { + const reverse = await performReverseLookupBatch(connection, domains) + const results: Domains[] = [] + + for (let i = 0; i < domains.length; i++) { + results.push({ + domainAddress: domains[i].toBase58(), + domainName: reverse[i], + }) + } + + setAccountDomains(results) + } + setIsLoading(false) + })() + }, [governedAccount, connection]) + + return { accountDomains, isLoading } +} + +export { useDomainsForAccount } diff --git a/hooks/useGovernanceAssets.ts b/hooks/useGovernanceAssets.ts index 944980ee31..a7b4974f7d 100644 --- a/hooks/useGovernanceAssets.ts +++ b/hooks/useGovernanceAssets.ts @@ -7,6 +7,7 @@ import { vsrPluginsPks } from './useVotingPlugins' export default function useGovernanceAssets() { const { ownVoterWeight, realm, symbol, governances, config } = useRealm() + const governedTokenAccounts: AssetAccount[] = useGovernanceAssetsStore( (s) => s.governedTokenAccounts ) @@ -256,6 +257,11 @@ export default function useGovernanceAssets() { name: 'Withdraw validator stake', isVisible: canUseAnyInstruction, }, + { + id: Instructions.TransferDomainName, + name: 'SNS Transfer Out Domain Name', + isVisible: canUseAnyInstruction, + }, { id: Instructions.EverlendDeposit, name: 'Everlend Deposit Funds', @@ -503,7 +509,6 @@ export default function useGovernanceAssets() { }, ...foresightInstructions, ] - return { governancesArray, getGovernancesByAccountType, diff --git a/package.json b/package.json index e36dad16c0..13dc7f8dfd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@blockworks-foundation/mango-client": "^3.6.14", "@blockworks-foundation/mango-v4": "^0.0.2", + "@bonfida/spl-name-service": "^0.1.47", "@bundlr-network/client": "^0.7.15", "@cardinal/namespaces-components": "^2.5.5", "@castlefinance/vault-core": "^0.1.3", diff --git a/pages/dao/[symbol]/proposal/components/instructions/TransferDomainName.tsx b/pages/dao/[symbol]/proposal/components/instructions/TransferDomainName.tsx new file mode 100644 index 0000000000..db0d4e5cb0 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/TransferDomainName.tsx @@ -0,0 +1,190 @@ +import React, { useContext, useEffect, useState } from 'react' +import * as yup from 'yup' +import useWalletStore from 'stores/useWalletStore' +import { + Governance, + ProgramAccount, + serializeInstructionToBase64, +} from '@solana/spl-governance' +import { PublicKey } from '@solana/web3.js' +import { validateInstruction } from '@utils/instructionTools' +import { + DomainNameTransferForm, + UiInstruction, +} from '@utils/uiTypes/proposalCreationTypes' +import { transferInstruction, NAME_PROGRAM_ID } from '@bonfida/spl-name-service' +import { NewProposalContext } from '../../new' +import GovernedAccountSelect from '../GovernedAccountSelect' + +import { LoadingDots } from '@components/Loading' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import Select from '@components/inputs/Select' +import Input from '@components/inputs/Input' +import { useDomainsForAccount } from '@hooks/useDomainNames' +import { isPublicKey } from '@tools/core/pubkey' + +const TransferDomainName = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount | null +}) => { + const connection = useWalletStore((s) => s.connection.current) + const shouldBeGoverned = index !== 0 && governance + const { handleSetInstructions } = useContext(NewProposalContext) + + const { assetAccounts } = useGovernanceAssets() + const governedAccount = assetAccounts.filter((acc) => acc.isSol)[0] + const { accountDomains, isLoading } = useDomainsForAccount( + connection, + governedAccount + ) + + const [formErrors, setFormErrors] = useState({}) + const [form, setForm] = useState({ + destinationAccount: '', + governedAccount: undefined, + domainAddress: undefined, + }) + + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + } + + async function getInstruction(): Promise { + const isValid = await validateInstruction({ + schema, + form, + setFormErrors, + }) + + const obj: UiInstruction = { + serializedInstruction: '', + isValid, + governance: governedAccount?.governance, + } + + if ( + isValid && + form.destinationAccount && + form.domainAddress && + form.governedAccount + ) { + const nameProgramId = new PublicKey(NAME_PROGRAM_ID) + const nameAccountKey = new PublicKey(form.domainAddress) + const newOwnerKey = new PublicKey(form.destinationAccount) + const nameOwner = governedAccount.pubkey + + const transferIx = transferInstruction( + nameProgramId, + nameAccountKey, + newOwnerKey, + nameOwner + ) + + obj.serializedInstruction = serializeInstructionToBase64(transferIx) + } + return obj + } + + useEffect(() => { + handleSetInstructions( + { governedAccount: governedAccount?.governance, getInstruction }, + index + ) + }, [form]) + + const schema = yup.object().shape({ + governedAccount: yup + .object() + .nullable() + .required('Governed account is required'), + destinationAccount: yup + .string() + .required('Please provide a valid destination account') + .test({ + name: 'is-valid-account', + test(val, ctx) { + if (!val || !isPublicKey(val)) { + return ctx.createError({ + message: 'Please verify the account address', + }) + } + return true + }, + }), + domainAddress: yup.string().required('Please select a domain name'), + }) + + return ( + <> + acc.isSol)} + onChange={(value) => { + handleSetForm({ value, propertyName: 'governedAccount' }) + }} + value={governedAccount} + error={formErrors['governedAccount']} + shouldBeGoverned={shouldBeGoverned} + governance={governance} + /> + + handleSetForm({ + propertyName: 'destinationAccount', + value: element.target.value, + }) + } + error={formErrors['destinationAccount']} + /> + {isLoading ? ( + + Looking up accountDomains... + + + ) : ( + d.domainAddress === form.domainAddress + )?.domainName + '.sol' + : '' + } + placeholder="Please select..." + error={formErrors['domainAddress']} + onChange={(value) => { + handleSetForm({ + value: accountDomains.find((d) => d.domainName === value) + ?.domainAddress, + propertyName: 'domainAddress', + }) + }} + > + {accountDomains?.map( + (domain, index) => + domain.domainAddress && ( + + {domain.domainName}.sol + {domain.domainAddress?.toString()} + + ) + )} + + )} + > + ) +} + +export default TransferDomainName diff --git a/pages/dao/[symbol]/proposal/new.tsx b/pages/dao/[symbol]/proposal/new.tsx index e1706a160c..2e94cd6e35 100644 --- a/pages/dao/[symbol]/proposal/new.tsx +++ b/pages/dao/[symbol]/proposal/new.tsx @@ -105,6 +105,7 @@ import PerpEdit from './components/instructions/Mango/MangoV4/PerpEdit' import Serum3RegisterMarket from './components/instructions/Mango/MangoV4/Serum3RegisterMarket' import PerpCreate from './components/instructions/Mango/MangoV4/PerpCreate' import TokenRegisterTrustless from './components/instructions/Mango/MangoV4/TokenRegisterTrustless' +import TransferDomainName from './components/instructions/TransferDomainName' import DepositForm from './components/instructions/Everlend/DepositForm' import WithdrawForm from './components/instructions/Everlend/WithdrawForm' import MakeChangeReferralFeeParams2 from './components/instructions/Mango/MakeChangeReferralFeeParams2' @@ -713,10 +714,13 @@ const New = () => { return case Instructions.UpdateTokenMetadata: return + case Instructions.TransferDomainName: + return case Instructions.EverlendDeposit: return case Instructions.EverlendWithdraw: return + default: null } diff --git a/utils/uiTypes/proposalCreationTypes.ts b/utils/uiTypes/proposalCreationTypes.ts index bfa14ece6e..fcd420f21d 100644 --- a/utils/uiTypes/proposalCreationTypes.ts +++ b/utils/uiTypes/proposalCreationTypes.ts @@ -32,6 +32,12 @@ export interface SplTokenTransferForm { mintInfo: MintInfo | undefined } +export interface DomainNameTransferForm { + destinationAccount: string + governedAccount: AssetAccount | undefined + domainAddress: string | undefined +} + export interface CastleDepositForm { amount: number | undefined governedTokenAccount: AssetAccount | undefined @@ -486,6 +492,7 @@ export enum Instructions { DeactivateValidatorStake, WithdrawValidatorStake, DifferValidatorStake, + TransferDomainName, EverlendDeposit, EverlendWithdraw, } diff --git a/yarn.lock b/yarn.lock index 8616ea13b4..c57db8f1dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1566,6 +1566,7 @@ "@bonfida/name-offers" "^0.0.1" "@ethersproject/sha2" "^5.5.0" + "@bonfida/spl-name-service@^0.1.50": version "0.1.50" resolved "https://registry.yarnpkg.com/@bonfida/spl-name-service/-/spl-name-service-0.1.50.tgz#462560199f6869fd97c8a19a8a09851ac15191db"