diff --git a/components/instructions/programs/symmetryV2.tsx b/components/instructions/programs/symmetryV2.tsx new file mode 100644 index 0000000000..3a0bd424f4 --- /dev/null +++ b/components/instructions/programs/symmetryV2.tsx @@ -0,0 +1,242 @@ +import { Connection } from "@solana/web3.js" +import { BasketsSDK } from "@symmetry-hq/baskets-sdk" +import BufferLayout from 'buffer-layout' + +const targetCompositionLayout = BufferLayout.seq( + BufferLayout.u8(), + 15, + 'targetComposition' +); +const targetWeightsLayout = BufferLayout.seq( + BufferLayout.u32(), + 15, + 'targetWeights' +); +const rebalanceAndLpLayout = BufferLayout.seq( + BufferLayout.u8(), + 2, + 'rebalanceAndLp' +); + +export const SYMMETRY_V2_INSTRUCTIONS = { + "2KehYt3KsEQR53jYcxjbQp2d2kCp4AkuQW68atufRwSr": { + 78: { + name: 'Symmetry: Withdraw from Basket', + accounts: [ + { name: 'Withdrawer' }, + { name: 'Basket Address' }, + { name: 'Symmetry PDA' }, + { name: 'Withdraw Temporary State Account' }, + { name: 'Withdrawer Basket Token Account' }, + { name: 'Basket Token Mint' }, + { name: 'System Program' }, + { name: 'Token Program' }, + { name: 'Rent Program' }, + { name: 'Account' }, + ], + getDataUI: async (connection: Connection, data: Uint8Array) => { + + //@ts-ignore + const { amount, rebalance } = BufferLayout.struct([ + BufferLayout.nu64('amount'), + BufferLayout.nu64('rebalance') + ]).decode(Buffer.from(data), 8) + + return ( + <> +

Withdraw Amount: {amount / 10**6}

+

Rebalance to USDC: {rebalance === 2 ? 'Yes' : 'No - Withdraw Assets Directly'}

+ + ) + }, + }, + 251: { + name: 'Symmetry: Deposit into Basket', + accounts: [ + { name: 'Depositor' }, + { name: 'Basket Address' }, + { name: 'Basket Token Mint' }, + { name: 'Symmetry Token List' }, + { name: 'Symmetry PDA' }, + { name: 'USDC PDA Account' }, + { name: 'Depositor USDC Account' }, + { name: 'Manager USDC Account' }, + { name: 'Symmetry Fee Account' }, + { name: 'Host Platform USDC Account' }, + { name: 'Depositor Basket Token Account' }, + { name: 'Temporary State Account for Deposit' }, + { name: 'System Program' }, + { name: 'Token Program' }, + { name: 'Rent' }, + { name: 'Associated Token Program' }, + { name: 'Account' }, + ], + getDataUI: async (connection: Connection, data: Uint8Array) => { + + //@ts-ignore + const { amount, rebalance } = BufferLayout.struct([ + BufferLayout.nu64('amount') + ]).decode(Buffer.from(data), 8) + + return ( + <> +

USDC Deposit Amount: {amount / 10**6}

+ + ) + }, + }, + 38: { + name: 'Symmetry: Edit Basket', + accounts: [ + { name: 'Basket Manager' }, + { name: 'Basket Address' }, + { name: 'Symmetry Token List' }, + { name: 'Manager Fee Receiver Address' }, + ], + getDataUI: async (connection: Connection, data: Uint8Array) => { + + //@ts-ignore + const { managerFee, rebalanceInterval, rebalanceThreshold, rebalanceSlippage, lpOffsetThreshold, rebalanceAndLp, numOfTokens, targetComposition, targetWeights } = BufferLayout.struct([ + BufferLayout.u16('managerFee'), + BufferLayout.nu64('rebalanceInterval'), + BufferLayout.u16('rebalanceThreshold'), + BufferLayout.u16('rebalanceSlippage'), + BufferLayout.u16('lpOffsetThreshold'), + rebalanceAndLpLayout, + BufferLayout.u8('numOfTokens'), + targetCompositionLayout, + targetWeightsLayout, + ]).decode(Buffer.from(data), 8) + + const basketsSdk = await BasketsSDK.init(connection); + const tokenData = basketsSdk.getTokenListData(); + let usdcIncluded = false; + let totalWeight = 0; targetWeights.map(w => totalWeight += w); + + let composition = targetComposition.map((tokenId, i) => { + let token = tokenData.filter(x => x.id == tokenId)[0] + if(token.id === 0) { + if(!usdcIncluded) { + usdcIncluded = true; + return { + ...token, + weight: targetWeights[i] / totalWeight * 100 + } + } + } else { + return { + ...token, + weight: targetWeights[i] / totalWeight * 100 + } + } + }).filter(x => x != null) + + return ( + <> +

Manager Fee: {managerFee / 100}%

+

Rebalance Check Interval: {rebalanceInterval / 60} minutes

+

Rebalance Trigger Threshold: {rebalanceThreshold / 100}%

+

Maximum Slippage Allowed During Rebalancing: {rebalanceSlippage / 100}%

+

Liquidity Provision Threshold: {lpOffsetThreshold / 100}%

+

Rebalancing Enabled: {rebalanceAndLp[0] === 0 ? "Yes" : "No"}

+

Liquidity Provision Enabled: {rebalanceAndLp[1] === 0 ? "No" : "Yes"}

+

Basket Composition Size: {numOfTokens} Tokens

+
+ Basket Composition: + { + composition.map((compItem, i) => { + return
+

{compItem.weight}% {compItem.symbol} ({compItem.tokenMint.slice(0,6)}...)

+
+ }) + } +
+ + ) + }, + }, + 47: { + name: 'Symmetry: Create Basket', + accounts: [ + { name: 'Manager' }, + { name: 'Token List' }, + { name: 'Basket Address' }, + { name: 'Symmetry PDA' }, + { name: 'Basket Token Mint' }, + { name: 'Symmetry Fee Collector' }, + { name: 'Metadata Account' }, + { name: 'Metadata Program' }, + { name: 'System Program' }, + { name: 'Token Program' }, + { name: 'Rent' }, + { name: 'Host Platform' }, + { name: 'Fee Collector Address' }, + { name: 'Account' }, + ], + getDataUI: async (connection: Connection, data: Uint8Array) => { + //@ts-ignore + const { managerFee, hostFee, basketType,rebalanceInterval, rebalanceThreshold, rebalanceSlippage, lpOffsetThreshold, rebalanceAndLp, numOfTokens, targetComposition, targetWeights, } = BufferLayout.struct([ + BufferLayout.u16('managerFee'), + BufferLayout.u16('hostFee'), + BufferLayout.u8('basketType'), + BufferLayout.nu64('rebalanceInterval'), + BufferLayout.u16('rebalanceThreshold'), + BufferLayout.u16('rebalanceSlippage'), + BufferLayout.u16('lpOffsetThreshold'), + rebalanceAndLpLayout, + BufferLayout.u8('numOfTokens'), + targetCompositionLayout, + targetWeightsLayout, + ]).decode(Buffer.from(data), 8) + + + let basketsSdk = await BasketsSDK.init(connection); + let tokenData = basketsSdk.getTokenListData(); + let usdcIncluded = false; + let totalWeight = 0; targetWeights.map(w => totalWeight += w); + + let composition = targetComposition.map((tokenId, i) => { + let token = tokenData.filter(x => x.id == tokenId)[0] + if(token.id === 0) { + if(!usdcIncluded) { + usdcIncluded = true; + return { + ...token, + weight: targetWeights[i] / totalWeight * 100 + } + } + } else + return { + ...token, + weight: targetWeights[i] / totalWeight * 100 + } + }).filter(x => x != null) + + return ( + <> +

Manager Fee: {managerFee / 100}%

+

Host Platform Fee: {hostFee / 100}%

+

Basket Type: {basketType === 0 ? "Bundle" : basketType === 1 ? "Portfolio" : "Private"}

+

Rebalance Check Interval: {rebalanceInterval / 60} minutes

+

Rebalance Trigger Threshold: {rebalanceThreshold / 100}%

+

Maximum Slippage Allowed During Rebalancing: {rebalanceSlippage / 100}%

+

Liquidity Provision Threshold: {lpOffsetThreshold / 100}%

+

Rebalancing Enabled: {rebalanceAndLp[0] === 0 ? "Yes" : "No"}

+

Liquidity Provision Enabled: {rebalanceAndLp[1] === 0 ? "No" : "Yes"}

+

Basket Composition Size: {numOfTokens} Tokens

+
+ Basket Composition: + { + composition.map((compItem, i) => { + return
+

{compItem.weight}% {compItem.symbol} ({compItem.tokenMint.slice(0,6)}...)

+
+ }) + } +
+ + ) + }, + }, + }, +} \ No newline at end of file diff --git a/components/instructions/tools.tsx b/components/instructions/tools.tsx index b598d91986..ac37a15726 100644 --- a/components/instructions/tools.tsx +++ b/components/instructions/tools.tsx @@ -36,6 +36,7 @@ import { STAKE_INSTRUCTIONS } from './programs/stake' import dayjs from 'dayjs' import { JUPITER_REF } from './programs/jupiterRef' import { STAKE_SANCTUM_INSTRUCTIONS } from './programs/stakeSanctum' +import { SYMMETRY_V2_INSTRUCTIONS } from './programs/symmetryV2' /** * Default governance program id instance @@ -516,6 +517,7 @@ export const INSTRUCTION_DESCRIPTORS = { ...STAKE_INSTRUCTIONS, ...STAKE_SANCTUM_INSTRUCTIONS, ...JUPITER_REF, + ...SYMMETRY_V2_INSTRUCTIONS, } export async function getInstructionDescriptor( diff --git a/hooks/useGovernanceAssets.ts b/hooks/useGovernanceAssets.ts index 2c19ce718d..279c368b5d 100644 --- a/hooks/useGovernanceAssets.ts +++ b/hooks/useGovernanceAssets.ts @@ -188,6 +188,10 @@ export default function useGovernanceAssets() { name: 'Solend', image: '/img/solend.png', }, + [PackageEnum.Symmetry]: { + name: 'Symmetry', + image: '/img/symmetry.png', + }, [PackageEnum.Squads]: { name: 'Squads', image: '/img/squads.png', @@ -713,6 +717,30 @@ export default function useGovernanceAssets() { name: 'Withdraw Funds', packageId: PackageEnum.Solend, }, + + /* + ███████ ██ ██ ███ ███ ███ ███ ███████ ████████ ██████ ██ ██ + ██ ██ ██ ████ ████ ████ ████ ██ ██ ██ ██ ██ ██ + ███████ ████ ██ ████ ██ ██ ████ ██ █████ ██ ██████ ████ + ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + ███████ ██ ██ ██ ██ ██ ███████ ██ ██ ██ ██ + */ + [Instructions.SymmetryCreateBasket]: { + name: 'Create Basket', + packageId: PackageEnum.Symmetry, + }, + [Instructions.SymmetryEditBasket]: { + name: 'Edit Basket', + packageId: PackageEnum.Symmetry, + }, + [Instructions.SymmetryDeposit]: { + name: 'Deposit into Basket', + packageId: PackageEnum.Symmetry, + }, + [Instructions.SymmetryWithdraw]: { + name: 'Withdraw from Basket', + packageId: PackageEnum.Symmetry, + }, /* ███████ ██████ ██ ██ █████ ██████ ███████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ diff --git a/package.json b/package.json index 9bc2c427ba..c2a767b86a 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "@sqds/mesh": "1.0.6", "@switchboard-xyz/sbv2-lite": "0.2.4", "@switchboard-xyz/solana.js": "3.2.5", + "@symmetry-hq/baskets-sdk": "0.0.45", "@tailwindcss/forms": "0.5.3", "@tailwindcss/line-clamp": "0.4.2", "@tanstack/react-query": "4.14.3", diff --git a/pages/dao/[symbol]/proposal/components/instructions/Symmetry/AddTokenToBasketModal.tsx b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/AddTokenToBasketModal.tsx new file mode 100644 index 0000000000..3460856022 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/AddTokenToBasketModal.tsx @@ -0,0 +1,73 @@ +import Modal from "@components/Modal" +import Input from "@components/inputs/Input" +import { useEffect, useState } from "react" + + +const AddTokenToBasketModal = ({ + open, + onClose, + supportedTokens, + onSelect +}:{ + open: boolean, + onClose: any, + supportedTokens: any, + onSelect: any +}) => { + const [allTokens, setAllTokens] = useState(supportedTokens); + const [searchValue, setSearchValue] = useState(''); + + useEffect(() => { + if(searchValue.length > 0){ + const filteredTokens = supportedTokens.filter((token: any) => { + return token.name.toLowerCase().includes(searchValue.toLowerCase()) || token.symbol.toLowerCase().includes(searchValue.toLowerCase()) + }) + setAllTokens(filteredTokens) + } else { + setAllTokens(supportedTokens) + } + + }, [searchValue, supportedTokens]) + return <> + { + open && + onClose()} + > +

Select a Token

+ setSearchValue(e.target.value)} type="text" /> +
+ { + allTokens.map((token, i) => { + return ( +
onSelect(token)} key={i} className='flex w-full gap-2 items-center justify-between bg-bkg-1 hover:bg-bkg-3 cursor-pointer p-2 rounded-md'> +
+

+ { + token.name + } +

+

+ { + token.symbol + } +

+
+ + { + token.tokenMint.slice(0, 6) + '...' + token.tokenMint.slice(-6) + } + +
+ ) + }) + } +
+
+ } + + +} + +export default AddTokenToBasketModal; \ No newline at end of file diff --git a/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryCreateBasket.tsx b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryCreateBasket.tsx new file mode 100644 index 0000000000..92a3d481be --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryCreateBasket.tsx @@ -0,0 +1,322 @@ +import { ProgramAccount, Governance, serializeInstructionToBase64 } from '@solana/spl-governance' +import { SymmetryCreateBasketForm, UiInstruction } from '@utils/uiTypes/proposalCreationTypes'; +import { useContext, useEffect, useState } from 'react'; +import Tooltip from '@components/Tooltip' +import Input from '@components/inputs/Input' +import { NewProposalContext } from '../../../new'; +import Switch from '@components/Switch'; +import { BasketsSDK } from "@symmetry-hq/baskets-sdk"; +import { createBasketIx } from "@symmetry-hq/baskets-sdk/dist/basketInstructions"; +import { useConnection } from '@solana/wallet-adapter-react'; +import Button from '@components/Button'; +import AddTokenToBasketModal from './AddTokenToBasketModal'; +import { TrashCan } from '@carbon/icons-react'; +import { PublicKey } from '@solana/web3.js'; +import { LinkIcon } from '@heroicons/react/solid'; +import useGovernanceAssets from '@hooks/useGovernanceAssets'; +import GovernedAccountSelect from '../../GovernedAccountSelect'; + +const SymmetryCreateBasket = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount +}) => { + const {connection} = useConnection(); + const { assetAccounts } = useGovernanceAssets() + const [form, setForm] = useState({ + basketName: "", + basketSymbol: "", + basketMetadataUrl: "", + basketType: 2, + basketComposition: [], + rebalanceThreshold: 1000, + rebalanceSlippageTolerance: 50, + depositFee: 10, + feeCollectorAddress: "", + liquidityProvision: false, + liquidityProvisionRange: 0, + }) + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext) + const shouldBeGoverned = !!(index !== 0 && governance) + const [supportedTokens, setSupportedTokens] = useState(null); + const [addTokenModal, setAddTokenModal] = useState(false); + + + + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + } + + useEffect(() => { + BasketsSDK.init(connection).then((sdk) => { + setSupportedTokens(sdk.getTokenListData()); + }); + }, []); + + useEffect(() => { + handleSetInstructions( + { governedAccount: form.governedAccount?.governance, getInstruction }, + index + ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree + }, [form]) + + async function getInstruction(): Promise { + + const basketParams = { + name: form.basketName, + symbol: form.basketSymbol, + uri: form.basketMetadataUrl, + hostPlatform: new PublicKey('4Vry5hGDmbwGwhjgegHmoitjMHniSotpjBFkRuDYHcDG'), + hostPlatformFee: 10, + //@ts-ignore + manager: form.governedAccount?.extensions.transferAddress, + managerFee: form.depositFee, + activelyManaged: 1, + rebalanceInterval: 3600, + rebalanceThreshold: form.rebalanceThreshold, + rebalanceSlippage: form.rebalanceSlippageTolerance, + lpOffsetThreshold: 0, + disableRebalance: false, + disableLp: !form.liquidityProvision, + composition: form.basketComposition.map((token) => { + return { + token: token.token, + weight: token.weight, + } + }), + feeDelegate: new PublicKey(form.feeCollectorAddress) + } + //@ts-ignore + const ix = await createBasketIx(connection, basketParams) + return { + serializedInstruction: serializeInstructionToBase64(ix), + isValid: true, + governance: form.governedAccount?.governance + }; + } + return ( + <> + +

Allow anyone to Deposit

+ handleSetForm({ value: x ? 2 : 1, propertyName: 'basketType' })}/> +
+ + handleSetForm({ + value: evt.target.value, + propertyName: 'basketName', + }) + } + error={formErrors['basketName']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'basketSymbol', + }) + } + error={formErrors['basketSymbol']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'basketMetadataUrl', + }) + } + error={formErrors['basketMetadataUrl']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'depositFee', + }) + } + error={formErrors['depositFee']} + /> + x.isSol)} + onChange={(value) => { + handleSetForm({ value, propertyName: 'governedAccount' }) + }} + value={form.governedAccount} + error={formErrors['governedAccount']} + shouldBeGoverned={shouldBeGoverned} + governance={governance} + type='wallet' + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'feeCollectorAddress', + }) + } + error={formErrors['feeCollectorAddress']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'rebalanceSlippageTolerance', + }) + } + error={formErrors['rebalanceSlippageTolerance']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'rebalanceThreshold', + }) + } + error={formErrors['rebalanceThreshold']} + /> +
+

Enable Passive Rebalancing

+

Plugs into DEX Aggregators to passively allow for favorable swaps that keep the composition rebalanced. + Earns Extra Yield. +

+ handleSetForm({ value: x, propertyName: 'liquidityProvision' })}/> +
+
+

Basket Composition

+
+
+

Token

+

Mint Address

+

Token Weight

+

Actions

+
+ { form.basketComposition.length > 0 ? + form.basketComposition.map((token, i) => { + return ( +
+
+

+ { + token.name + } +

+

+ { + token.symbol + } +

+
+
+

+ { + token.token.toBase58().slice(0,6) + '...' + token.token.toBase58().slice(-6) + } +

+ + + +
+
+ { + const newComposition = form.basketComposition; + newComposition[i].weight = parseFloat(evt.target.value); + setForm({ + ...form, + basketComposition: newComposition + }); + } + } + /> +
+
+ { + const newComposition = form.basketComposition; + newComposition.splice(i, 1); + setForm({ + ...form, + basketComposition: newComposition + }); + }} + /> +
+
+ ) + }) + : +

Composition Empty. Add tokens below

+ } +
+ { + form.basketComposition.length < 15 && supportedTokens && ( + + ) + } + { + addTokenModal && + setAddTokenModal(false)} + supportedTokens={supportedTokens} + onSelect={(token) => { + if(form.basketComposition.find((t) => t.token.toBase58() === token.tokenMint) + || form.basketComposition.length >= 15) { + return; + } + + setForm({ + ...form, + basketComposition: [ + ...form.basketComposition, + { + name: token.name, + symbol: token.symbol, + token: new PublicKey(token.tokenMint), + weight: 0, + } + ] + }); + setAddTokenModal(false); + }} + /> + } + +
+ + ) +} + +export default SymmetryCreateBasket; \ No newline at end of file diff --git a/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryDeposit.tsx b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryDeposit.tsx new file mode 100644 index 0000000000..ea8cb31184 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryDeposit.tsx @@ -0,0 +1,160 @@ +import { ProgramAccount, Governance, serializeInstructionToBase64 } from '@solana/spl-governance' +import { SymmetryDepositForm, UiInstruction } from '@utils/uiTypes/proposalCreationTypes'; +import { useContext, useEffect, useState } from 'react'; +import Input from '@components/inputs/Input' +import { NewProposalContext } from '../../../new'; +import { BasketsSDK, FilterOption } from "@symmetry-hq/baskets-sdk"; +import { buyBasketIx } from "@symmetry-hq/baskets-sdk/dist/basketInstructions"; + +import { useConnection } from '@solana/wallet-adapter-react'; +import { PublicKey } from '@solana/web3.js'; +import useGovernanceAssets from '@hooks/useGovernanceAssets'; +import Select from '@components/inputs/Select'; +import GovernedAccountSelect from '../../GovernedAccountSelect'; + +const SymmetryDeposit = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount +}) => { + const {connection} = useConnection(); + const { assetAccounts } = useGovernanceAssets(); + const [basketsSdk, setBasketSdk] = useState(undefined); + const [form, setForm] = useState({ + governedAccount: undefined, + basketAddress: undefined, + depositToken: undefined, + depositAmount: 0, + }) + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext); + const [managedBaskets, setManagedBaskets] = useState(undefined); + const shouldBeGoverned = !!(index !== 0 && governance) + const [assetAccountsLoaded, setAssetAccountsLoaded] = useState(false); + + + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + } + + useEffect(() => { + if(assetAccounts && assetAccounts.length > 0 && !assetAccountsLoaded) + setAssetAccountsLoaded(true); + }, [assetAccounts]); + + useEffect(() => { + if(assetAccountsLoaded) { + const basketsOwnerAccounts: FilterOption[] = assetAccounts.filter(x => x.isSol).map((token) => { + return { + filterType: 'manager', + filterPubkey: token.pubkey + } + }) + BasketsSDK.init(connection).then((sdk) => { + setBasketSdk(sdk); + sdk.findBaskets(basketsOwnerAccounts).then((baskets) => { + sdk.getCurrentCompositions(baskets).then((compositions) => { + const basketAccounts:any[] = []; + baskets.map((basket, i) => { + + basketAccounts.push({ + governedAccount: assetAccounts.filter(x => x.pubkey.toBase58() === basket.data.manager.toBase58())[0], + basket: basket, + composition: compositions[i] + }); + }); + setManagedBaskets(basketAccounts); + }); + }); + }); + } + }, [assetAccountsLoaded]); + + useEffect(() => { + handleSetInstructions( + { governedAccount: form.governedAccount?.governance, getInstruction }, + index + ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree + }, [form]) + + async function getInstruction(): Promise { + const ix = await buyBasketIx( + connection, + //@ts-ignore + form.governedAccount?.governance.nativeTreasuryAddress, + form.basketAddress, + Number(form.depositAmount) + ) + + return { + serializedInstruction: serializeInstructionToBase64(ix), + isValid: true, + governance: form.governedAccount?.governance + }; + } + + return <> + { + managedBaskets ? + + : +

Loading Baskets Managed by the DAO

+ } + { + form.basketAddress && +
+

Selected Basket:

+

{form.basketAddress.toBase58()}

+ + +

View Basket on Symmetry

+
+
+ } + x.extensions.mint?.publicKey.toBase58() === 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')} /* Only allow USDC deposits for now */ + onChange={(value) => { + handleSetForm({ value, propertyName: 'governedAccount' }) + }} + value={form.governedAccount} + error={formErrors['governedAccount']} + shouldBeGoverned={shouldBeGoverned} + governance={governance} + type='token' + /> + + handleSetForm({ propertyName: 'depositAmount', value: e.target.value })} + error={formErrors['depositAmount']} + /> + +} + +export default SymmetryDeposit; \ No newline at end of file diff --git a/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryEditBasket.tsx b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryEditBasket.tsx new file mode 100644 index 0000000000..49c84f47e8 --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryEditBasket.tsx @@ -0,0 +1,382 @@ +import { ProgramAccount, Governance, serializeInstructionToBase64 } from '@solana/spl-governance' +import { SymmetryEditBasketForm, UiInstruction } from '@utils/uiTypes/proposalCreationTypes'; +import { useContext, useEffect, useState } from 'react'; +import Tooltip from '@components/Tooltip' +import Input from '@components/inputs/Input' +import { NewProposalContext } from '../../../new'; +import Switch from '@components/Switch'; +import { BasketsSDK, FilterOption } from "@symmetry-hq/baskets-sdk"; +import { editBasketIx } from "@symmetry-hq/baskets-sdk/dist/basketInstructions"; +import { useConnection } from '@solana/wallet-adapter-react'; +import Button from '@components/Button'; +import AddTokenToBasketModal from './AddTokenToBasketModal'; +import { TrashCan } from '@carbon/icons-react'; +import { PublicKey } from '@solana/web3.js'; +import { LinkIcon } from '@heroicons/react/solid'; +import useGovernanceAssets from '@hooks/useGovernanceAssets'; +import Select from '@components/inputs/Select'; + +const SymmetryEditBasket = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount +}) => { + const {connection} = useConnection(); + const { assetAccounts } = useGovernanceAssets() + const [form, setForm] = useState({ + + basketName: "", + basketSymbol: "", + basketMetadataUrl: "", + basketType: 2, + basketComposition: [], + rebalanceThreshold: 1000, + rebalanceSlippageTolerance: 50, + depositFee: 10, + feeCollectorAddress: "", + liquidityProvision: false, + liquidityProvisionRange: 0, + }) + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext); + const [managedBaskets, setManagedBaskets] = useState(undefined); + const shouldBeGoverned = !!(index !== 0 && governance) + const [addTokenModal, setAddTokenModal] = useState(false); + const [supportedTokens, setSupportedTokens] = useState(null); + const [assetAccountsLoaded, setAssetAccountsLoaded] = useState(false); + + + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + } + + const handleSelectBasket = (address: string) => { + const foundBasket = managedBaskets.filter(x => x.basket.ownAddress.toBase58() === address)[0] + if(!foundBasket) return; + const formData = { + governedAccount: foundBasket.governedAccount, + basketAddress: new PublicKey(address), + basketName: String.fromCharCode.apply(null, foundBasket.basket.data.name), + basketSymbol: String.fromCharCode.apply(null, foundBasket.basket.data.symbol), + basketMetadataUrl: String.fromCharCode.apply(null, foundBasket.basket.data.uri), + basketType: foundBasket.basket.data.activelyManaged.toNumber(), + basketComposition: foundBasket.composition.currentComposition.map((comp) => { + return { + name: comp.name, + symbol: comp.symbol, + token: new PublicKey(comp.mintAddress), + weight: comp.targetWeight + } + }), + rebalanceThreshold: foundBasket.basket.data.rebalanceThreshold.toNumber(), + rebalanceSlippageTolerance: foundBasket.basket.data.rebalanceSlippage.toNumber(), + depositFee: foundBasket.basket.data.managerFee.toNumber(), + feeCollectorAddress: foundBasket.basket.data.feeDelegate.toBase58(), + liquidityProvision: foundBasket.basket.data.disableLp.toNumber === 0, + liquidityProvisionRange: foundBasket.basket.data.lpOffsetThreshold.toNumber() + } + setForm(formData); + } + + useEffect(() => { + if(assetAccounts && assetAccounts.length > 0 && !assetAccountsLoaded) + setAssetAccountsLoaded(true); + }, [assetAccounts]); + + useEffect(() => { + if(assetAccountsLoaded) { + const basketsOwnerAccounts: FilterOption[] = assetAccounts.filter(x => x.isSol).map((token) => { + return { + filterType: 'manager', + filterPubkey: token.pubkey + } + }) + if(basketsOwnerAccounts.length > 0) { + BasketsSDK.init(connection).then((sdk) => { + setSupportedTokens(sdk.getTokenListData()); + sdk.findBaskets(basketsOwnerAccounts).then((baskets) => { + sdk.getCurrentCompositions(baskets).then((compositions) => { + const basketAccounts:any[] = []; + baskets.map((basket, i) => { + + basketAccounts.push({ + governedAccount: assetAccounts.filter(x => x.pubkey.toBase58() === basket.data.manager.toBase58())[0], + basket: basket, + composition: compositions[i] + }); + }); + setManagedBaskets(basketAccounts); + }); + }); + }); + } + } + }, [assetAccountsLoaded]); + + useEffect(() => { + handleSetInstructions( + { governedAccount: form.governedAccount?.governance, getInstruction }, + index + ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree + }, [form]) + + async function getInstruction(): Promise { + const basketParams = { + //@ts-ignore + managerFee: form.depositFee, + activelyManaged: form.basketType, + rebalanceInterval: 3600, + rebalanceThreshold: form.rebalanceThreshold, + rebalanceSlippage: form.rebalanceSlippageTolerance, + lpOffsetThreshold: 0, + disableRebalance: false, + disableLp: !form.liquidityProvision, + composition: form.basketComposition.map((token) => { + return { + token: token.token, + weight: token.weight, + } + }), + feeDelegate: new PublicKey(form.feeCollectorAddress) + } + //@ts-ignore + const ix = await editBasketIx(connection, form.basketAddress, basketParams) + + return { + serializedInstruction: serializeInstructionToBase64(ix), + isValid: true, + governance: form.governedAccount?.governance + }; + } + + return <> + { + managedBaskets ? + + : +

Loading Baskets Managed by the DAO

+ } + + { + form.basketAddress && + <> + + +

View Basket on Symmetry

+
+ +

Allow anyone to Deposit

+ handleSetForm({ value: x ? 1 : 2, propertyName: 'basketType' })}/> +
+ null} + error={formErrors['basketName']} + /> + null } + error={formErrors['basketSymbol']} + /> + null} + error={formErrors['basketMetadataUrl']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'depositFee', + }) + } + error={formErrors['depositFee']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'feeCollectorAddress', + }) + } + error={formErrors['feeCollectorAddress']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'rebalanceSlippageTolerance', + }) + } + error={formErrors['rebalanceSlippageTolerance']} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'rebalanceThreshold', + }) + } + error={formErrors['rebalanceThreshold']} + /> +
+

Enable Passive Rebalancing

+

Plugs into DEX Aggregators to passively allow for favorable swaps that keep the composition rebalanced. + Earns Extra Yield. +

+ handleSetForm({ value: x, propertyName: 'liquidityProvision' })}/> +
+
+

Basket Composition

+
+
+

Token

+

Mint Address

+

Token Weight

+

Actions

+
+ { form.basketComposition.length > 0 ? + form.basketComposition.map((token, i) => { + return ( +
+
+

+ { + token.name + } +

+

+ { + token.symbol + } +

+
+
+

+ { + token.token.toBase58().slice(0,6) + '...' + token.token.toBase58().slice(-6) + } +

+ + + +
+
+ { + const newComposition = form.basketComposition; + newComposition[i].weight = parseFloat(evt.target.value); + setForm({ + ...form, + basketComposition: newComposition + }); + } + } + /> +
+
+ { + const newComposition = form.basketComposition; + newComposition.splice(i, 1); + setForm({ + ...form, + basketComposition: newComposition + }); + }} + /> +
+
+ ) + }) + : +

Composition Empty. Add tokens below

+ } +
+ { + form.basketComposition.length < 15 && supportedTokens && ( + + ) + } + { + addTokenModal && + setAddTokenModal(false)} + supportedTokens={supportedTokens} + onSelect={(token) => { + if(form.basketComposition.find((t) => t.token.toBase58() === token.tokenMint) + || form.basketComposition.length >= 15) { + return; + } + + setForm({ + ...form, + basketComposition: [ + ...form.basketComposition, + { + name: token.name, + symbol: token.symbol, + token: new PublicKey(token.tokenMint), + weight: 0, + } + ] + }); + setAddTokenModal(false); + }} + /> + } + +
+ + } + +} + +export default SymmetryEditBasket; \ No newline at end of file diff --git a/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryWithdraw.tsx b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryWithdraw.tsx new file mode 100644 index 0000000000..ee562d3d9b --- /dev/null +++ b/pages/dao/[symbol]/proposal/components/instructions/Symmetry/SymmetryWithdraw.tsx @@ -0,0 +1,187 @@ +import { ProgramAccount, Governance, serializeInstructionToBase64 } from '@solana/spl-governance' +import { SymmetryWithdrawForm, UiInstruction } from '@utils/uiTypes/proposalCreationTypes'; +import { useContext, useEffect, useState } from 'react'; +import Input from '@components/inputs/Input' +import { NewProposalContext } from '../../../new'; +import { BasketsSDK, FilterOption } from "@symmetry-hq/baskets-sdk"; +import { sellBasketIx } from "@symmetry-hq/baskets-sdk/dist/basketInstructions"; +import { useConnection } from '@solana/wallet-adapter-react'; +import useGovernanceAssets from '@hooks/useGovernanceAssets'; +import GovernedAccountSelect from '../../GovernedAccountSelect'; +import Select from '@components/inputs/Select'; +import { PublicKey } from '@solana/web3.js'; + +const SymmetryWithdraw = ({ + index, + governance, +}: { + index: number + governance: ProgramAccount +}) => { + const {connection} = useConnection(); + const { assetAccounts } = useGovernanceAssets(); + const [basketsSdk, setBasketSdk] = useState(undefined); + const [form, setForm] = useState({ + governedAccount: undefined, + basketAddress: undefined, + withdrawAmount: 0, + withdrawType: 3 + }) + const [formErrors, setFormErrors] = useState({}) + const { handleSetInstructions } = useContext(NewProposalContext); + const [managedBaskets, setManagedBaskets] = useState(undefined); + const shouldBeGoverned = !!(index !== 0 && governance) + const [assetAccountsLoaded, setAssetAccountsLoaded] = useState(false); + const [selectedBasket, setSelectedBasket] = useState(undefined); + + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + } + + const handleSelectBasket = (basket: any) => { + handleSetForm({ propertyName: 'basketAddress', value: basket.basket.ownAddress }) + } + + useEffect(() => { + if(assetAccounts && assetAccounts.length > 0 && !assetAccountsLoaded) + setAssetAccountsLoaded(true); + }, [assetAccounts]); + + useEffect(() => { + if(assetAccountsLoaded) { + const basketsOwnerAccounts: FilterOption[] = assetAccounts.filter(x => x.isSol).map((token) => { + return { + filterType: 'manager', + filterPubkey: token.pubkey + } + }) + BasketsSDK.init(connection).then((sdk) => { + setBasketSdk(sdk); + sdk.findBaskets(basketsOwnerAccounts).then((baskets) => { + sdk.getCurrentCompositions(baskets).then((compositions) => { + const basketAccounts:any[] = []; + baskets.map((basket, i) => { + + basketAccounts.push({ + governedAccount: assetAccounts.filter(x => x.pubkey.toBase58() === basket.data.manager.toBase58())[0], + basket: basket, + composition: compositions[i] + }); + }); + setManagedBaskets(basketAccounts); + }); + }); + }); + } + }, [assetAccountsLoaded]); + + useEffect(() => { + handleSetInstructions( + { governedAccount: form.governedAccount?.governance, getInstruction }, + index + ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree + }, [form]) + + async function getInstruction(): Promise { + const ix = await sellBasketIx( + connection, + //@ts-ignore + form.governedAccount?.governance.nativeTreasuryAddress, + form.basketAddress, + Number(form.withdrawAmount), + form.withdrawType + ) + + return { + serializedInstruction: serializeInstructionToBase64(ix), + isValid: true, + governance: form.governedAccount?.governance + }; + } + + return <> + { + managedBaskets ? + + : +

Loading Baskets Managed by the DAO

+ } + { + form.basketAddress && +
+

Selected Basket:

+

{form.basketAddress.toBase58()}

+ + +

View Basket on Symmetry

+
+
+ } + { + form.basketAddress && + x.isToken).filter(x => x.extensions.mint?.publicKey.toBase58() === selectedBasket?.composition?.basketTokenMint)} + onChange={(value) => { + handleSetForm({ value, propertyName: 'governedAccount' }) + }} + value={form.governedAccount} + error={formErrors['governedAccount']} + shouldBeGoverned={shouldBeGoverned} + governance={governance} + type='token' + /> + } + + handleSetForm({ propertyName: 'withdrawAmount', value: e.target.value })} + error={formErrors['withdrawAmount']} + /> + + +} + +export default SymmetryWithdraw; \ No newline at end of file diff --git a/pages/dao/[symbol]/proposal/new.tsx b/pages/dao/[symbol]/proposal/new.tsx index b543278a20..3fdd06afbb 100644 --- a/pages/dao/[symbol]/proposal/new.tsx +++ b/pages/dao/[symbol]/proposal/new.tsx @@ -141,6 +141,10 @@ import PythRecoverAccount from './components/instructions/Pyth/PythRecoverAccoun import { useVoteByCouncilToggle } from '@hooks/useVoteByCouncilToggle' import BurnTokens from './components/instructions/BurnTokens' import RemoveLockup from './components/instructions/Validators/removeLockup' +import SymmetryCreateBasket from './components/instructions/Symmetry/SymmetryCreateBasket' +import SymmetryEditBasket from './components/instructions/Symmetry/SymmetryEditBasket' +import SymmetryDeposit from './components/instructions/Symmetry/SymmetryDeposit' +import SymmetryWithdraw from './components/instructions/Symmetry/SymmetryWithdraw' const TITLE_LENGTH_LIMIT = 130 // the true length limit is either at the tx size level, and maybe also the total account size level (I can't remember) @@ -598,6 +602,10 @@ const New = () => { [Instructions.RemoveServiceFromDID]: RemoveServiceFromDID, [Instructions.RevokeGoverningTokens]: RevokeGoverningTokens, [Instructions.SetMintAuthority]: SetMintAuthority, + [Instructions.SymmetryCreateBasket]: SymmetryCreateBasket, + [Instructions.SymmetryEditBasket]: SymmetryEditBasket, + [Instructions.SymmetryDeposit]: SymmetryDeposit, + [Instructions.SymmetryWithdraw]: SymmetryWithdraw, }), [governance?.pubkey?.toBase58()] ) diff --git a/public/img/symmetry.png b/public/img/symmetry.png new file mode 100644 index 0000000000..27ac77afe4 Binary files /dev/null and b/public/img/symmetry.png differ diff --git a/utils/uiTypes/proposalCreationTypes.ts b/utils/uiTypes/proposalCreationTypes.ts index 57f3b0adef..172f480a25 100644 --- a/utils/uiTypes/proposalCreationTypes.ts +++ b/utils/uiTypes/proposalCreationTypes.ts @@ -24,6 +24,7 @@ export enum PackageEnum { Pyth, Serum, Solend, + Symmetry, Squads, Switchboard, VsrPlugin, @@ -392,6 +393,10 @@ export enum Instructions { SetMintAuthority, SanctumDepositStake, SanctumWithdrawStake, + SymmetryCreateBasket, + SymmetryEditBasket, + SymmetryDeposit, + SymmetryWithdraw } export interface ComponentInstructionData { @@ -548,3 +553,59 @@ export interface DualFinanceVoteDepositForm { realm: string | undefined delegateToken: AssetAccount | undefined } + +export interface SymmetryCreateBasketForm { + governedAccount?: AssetAccount, + basketType: number, + basketName: string, + basketSymbol: string, + basketMetadataUrl: string, + basketComposition: { + name: string, + symbol: string, + token: PublicKey; + weight: number; + }[], + rebalanceThreshold: number, + rebalanceSlippageTolerance: number, + depositFee: number, + feeCollectorAddress:string, + liquidityProvision: boolean, + liquidityProvisionRange: number, +} + + +export interface SymmetryEditBasketForm { + governedAccount?: AssetAccount, + basketAddress?: PublicKey, + basketType: number, + basketName: string, + basketSymbol: string, + basketMetadataUrl: string, + basketComposition: { + name: string, + symbol: string, + token: PublicKey; + weight: number; + }[], + rebalanceThreshold: number, + rebalanceSlippageTolerance: number, + depositFee: number, + feeCollectorAddress:string, + liquidityProvision: boolean, + liquidityProvisionRange: number, +} + +export interface SymmetryDepositForm { + governedAccount?: AssetAccount, + basketAddress?: PublicKey, + depositToken?: PublicKey, + depositAmount: number, +} + +export interface SymmetryWithdrawForm { + governedAccount?: AssetAccount, + basketAddress?: PublicKey, + withdrawAmount: number, + withdrawType: number +} diff --git a/yarn.lock b/yarn.lock index 571ba93b48..1f83dbbc8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5521,6 +5521,19 @@ dotenv "^16.3.1" lodash "^4.17.21" +"@symmetry-hq/baskets-sdk@0.0.45": + version "0.0.45" + resolved "https://registry.yarnpkg.com/@symmetry-hq/baskets-sdk/-/baskets-sdk-0.0.45.tgz#19d1c96bb0f659eccd7969aad3513bc4a7006a6b" + integrity sha512-w5w3yZl+qTwEAL8b21XGesuLX3HPpaQYyYkkRbwQBheqCJ8Geyg7/NyuZRF+8/a0XdubiQfW70QzBHZch6J0nA== + dependencies: + "@coral-xyz/anchor" "0.29.0" + "@metaplex-foundation/js" "0.19.4" + "@pythnetwork/client" "2.17.0" + "@solana/web3.js" "1.78.8" + "@types/crypto-js" "4.1.1" + axios "0.26.1" + crypto-js "4.1.1" + "@tailwindcss/forms@0.5.3": version "0.5.3" resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7" @@ -5798,6 +5811,11 @@ dependencies: "@types/node" "*" +"@types/crypto-js@4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.1.1.tgz#602859584cecc91894eb23a4892f38cfa927890d" + integrity sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA== + "@types/d3-array@*", "@types/d3-array@^3.0.3": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.4.tgz#44eebe40be57476cad6a0cd6a85b0f57d54185a2" @@ -8461,7 +8479,7 @@ crypto-hash@^1.3.0: resolved "https://registry.yarnpkg.com/crypto-hash/-/crypto-hash-1.3.0.tgz#b402cb08f4529e9f4f09346c3e275942f845e247" integrity sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== -crypto-js@>=4.1.1, crypto-js@^3.1.9-1, crypto-js@^4.1.1, crypto-js@^4.2.0: +crypto-js@4.1.1, crypto-js@>=4.1.1, crypto-js@^3.1.9-1, crypto-js@^4.1.1, crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==