diff --git a/components/ContributionModal.tsx b/components/ContributionModal.tsx index c683d3c..733762f 100644 --- a/components/ContributionModal.tsx +++ b/components/ContributionModal.tsx @@ -4,13 +4,13 @@ import tw from 'twin.macro' import { LockClosedIcon, LockOpenIcon } from '@heroicons/react/outline' import { LinkIcon } from '@heroicons/react/solid' import useWalletStore from '../stores/useWalletStore' -import { getUsdcBalance } from '../utils' import Input from './Input' import Button from './Button' import { ConnectWalletButtonSmall } from './ConnectWalletButton' import Slider from './Slider' import Loading from './Loading' import WalletIcon from './WalletIcon' +import useLargestAccounts from '../hooks/useLargestAccounts' const StyledModalWrapper = styled.div` height: 414px; @@ -40,9 +40,15 @@ const StyledModalBorder = styled.div` ` const ContributionModal = () => { + const actions = useWalletStore((s) => s.actions) const connected = useWalletStore((s) => s.connected) const wallet = useWalletStore((s) => s.current) - const usdcBalance = getUsdcBalance() + const largestAccounts = useLargestAccounts() + + const usdcBalance = largestAccounts.usdc?.balance || 0 + const redeemableBalance = largestAccounts.redeemable?.balance || 0 + + console.log({ usdcBalance, redeemableBalance }) const [contributionAmount, setContributionAmount] = useState(0) const [sliderPercentage, setSliderPercentage] = useState(0) @@ -52,6 +58,11 @@ const ContributionModal = () => { const [loading, setLoading] = useState(true) const [maxButtonTransition, setMaxButtonTransition] = useState(false) + useEffect(() => { + console.log('setContributionAmount from redeemableBalance') + setContributionAmount(redeemableBalance) + }, [redeemableBalance]) + const handleConnectDisconnect = () => { if (connected) { setSubmitted(false) @@ -103,12 +114,9 @@ const ContributionModal = () => { useEffect(() => { if (submitting) { - // TODO: add submission here - const submitTimer = setTimeout(() => { - setSubmitted(true) - setSubmitting(false) - }, 2000) - return () => clearTimeout(submitTimer) + actions.submitContribution(contributionAmount) + setSubmitted(true) + setSubmitting(false) } }, [submitting]) diff --git a/hooks/useLargestAccounts.tsx b/hooks/useLargestAccounts.tsx new file mode 100644 index 0000000..3604a26 --- /dev/null +++ b/hooks/useLargestAccounts.tsx @@ -0,0 +1,58 @@ +import BN from 'bn.js' +import useWalletStore from '../stores/useWalletStore' +import { ProgramAccount, TokenAccount } from '../utils/tokens' + +function fixedPointToNumber(value: BN, decimals: number) { + const divisor = new BN(10).pow(new BN(decimals)) + const quotient = value.div(divisor) + const remainder = value.mod(divisor) + return quotient.toNumber() + remainder.toNumber() / divisor.toNumber() +} + +function calculateBalance(mints, account: TokenAccount): number { + const mint = mints[account.mint.toBase58()] + return mint ? fixedPointToNumber(account.amount, mint.decimals) : 0 +} + +export function findLargestBalanceAccountForMint( + mints, + tokenAccounts: ProgramAccount[], + mintPk +) { + const accounts = tokenAccounts.filter((a) => a.account.mint.equals(mintPk)) + if (!accounts.length) return undefined + + const balances = accounts.map((a) => calculateBalance(mints, a.account)) + const maxBalanceAccountIndex = balances.reduce( + (iMax, bal, iBal) => (bal > balances[iMax] ? iBal : iMax), + 0 + ) + const account = accounts[maxBalanceAccountIndex] + const balance = balances[maxBalanceAccountIndex] + + console.log( + 'findLargestBalanceAccountForMint', + maxBalanceAccountIndex, + account, + balance + ) + + return { account, balance } +} + +export default function useLargestAccounts() { + const { pool, tokenAccounts, mints, usdcVault } = useWalletStore( + (state) => state + ) + const usdc = usdcVault + ? findLargestBalanceAccountForMint(mints, tokenAccounts, usdcVault.mint) + : undefined + const redeemable = pool + ? findLargestBalanceAccountForMint( + mints, + tokenAccounts, + pool.redeemableMint + ) + : undefined + return { usdc, redeemable } +} diff --git a/hooks/useWallet.tsx b/hooks/useWallet.tsx index 51ba957..e3cc985 100644 --- a/hooks/useWallet.tsx +++ b/hooks/useWallet.tsx @@ -103,10 +103,9 @@ export default function useWallet() { wallet.publicKey.toString().substr(-5), }) await actions.fetchPool() - await actions.fetchVault() await Promise.all([ actions.fetchWalletTokenAccounts(), - actions.fetchVaultMint(), + actions.fetchMints(), ]) }) wallet.on('disconnect', () => { @@ -131,7 +130,7 @@ export default function useWallet() { }, [wallet, setWalletStore]) useInterval(async () => { - await actions.fetchVault() + await actions.fetchUsdcVault() }, 20 * SECONDS) return { connected, wallet } diff --git a/idls/ido_pool.json b/idls/ido_pool.json index 8f638bb..9b33542 100644 --- a/idls/ido_pool.json +++ b/idls/ido_pool.json @@ -397,6 +397,6 @@ } ], "metadata": { - "address": "5JTPKQJPkvrMzfDyEHX6EiaEYbR5QxXDkNQ66BoadgUn" + "address": "2oBtRS2AAQfsMxXQfg41fKFY9zjvHwSSD7G5idrCFziV" } } diff --git a/stores/useWalletStore.tsx b/stores/useWalletStore.tsx index b8cd5fe..be7a2ec 100644 --- a/stores/useWalletStore.tsx +++ b/stores/useWalletStore.tsx @@ -1,6 +1,6 @@ import create, { State } from 'zustand' import produce from 'immer' -import { Connection, PublicKey } from '@solana/web3.js' +import { Connection, PublicKey, Transaction } from '@solana/web3.js' import * as anchor from '@project-serum/anchor' import { EndpointInfo, WalletAdapter } from '../@types/types' @@ -16,6 +16,11 @@ import { MintAccount, getTokenAccount, } from '../utils/tokens' +import { findLargestBalanceAccountForMint } from '../hooks/useLargestAccounts' +import { TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { token } from '@project-serum/anchor/dist/utils' +import { createAssociatedTokenAccount } from '../utils/associated' +import { sendTransaction } from '../utils/send' export const ENDPOINTS: EndpointInfo[] = [ { @@ -27,10 +32,10 @@ export const ENDPOINTS: EndpointInfo[] = [ }, { name: 'devnet', - url: 'https://devnet.solana.com', - websocket: 'https://devnet.solana.com', - programId: 'E5s3D6B3PJinuB9kb3dicxfi3qUNLUGX6hoPawhbqagt', - poolKey: '', + url: 'https://api.devnet.solana.com', + websocket: 'https://api.devnet.solana.com', + programId: '2oBtRS2AAQfsMxXQfg41fKFY9zjvHwSSD7G5idrCFziV', + poolKey: 'ZfSZf2xrNLBrfY37TwJLoHv9qdKBQpkbZuPrq5FT8U9', }, { name: 'localnet', @@ -41,7 +46,7 @@ export const ENDPOINTS: EndpointInfo[] = [ }, ] -const CLUSTER = 'localnet' +const CLUSTER = 'devnet' const ENDPOINT = ENDPOINTS.find((e) => e.name === CLUSTER) const DEFAULT_CONNECTION = new Connection(ENDPOINT.url, 'recent') const WEBSOCKET_CONNECTION = new Connection(ENDPOINT.websocket, 'recent') @@ -72,10 +77,13 @@ interface WalletStore extends State { } current: WalletAdapter | undefined providerUrl: string + provider: anchor.Provider | undefined + program: anchor.Program | undefined + pool: PoolAccount | undefined + mangoVault: TokenAccount | undefined + usdcVault: TokenAccount | undefined tokenAccounts: ProgramAccount[] mints: { [pubkey: string]: MintAccount } - pool: PoolAccount | undefined - vault: TokenAccount | undefined set: (x: any) => void actions: any } @@ -91,10 +99,13 @@ const useWalletStore = create((set, get) => ({ }, current: null, providerUrl: null, + provider: undefined, + program: undefined, + pool: undefined, + mangoVault: undefined, + usdcVault: undefined, tokenAccounts: [], mints: {}, - pool: undefined, - vault: undefined, actions: { async fetchPool() { const connection = get().connection.current @@ -110,15 +121,23 @@ const useWalletStore = create((set, get) => ({ anchor.Provider.defaultOptions() ) const program = new anchor.Program(poolIdl, programId, provider) - console.log(program) - const pool = (await program.account.poolAccount.fetch( POOL_PK )) as PoolAccount - console.log(pool) + + const [usdcVault, mangoVault] = await Promise.all([ + getTokenAccount(connection, pool.poolUsdc), + getTokenAccount(connection, pool.poolWatermelon), + ]) + + console.log({ program, pool, usdcVault, mangoVault }) set((state) => { + state.provider = provider + state.program = program state.pool = pool + state.usdcVault = usdcVault.account + state.mangoVault = mangoVault.account }) } }, @@ -135,6 +154,8 @@ const useWalletStore = create((set, get) => ({ walletOwner ) + console.log('fetchWalletTokenAccounts', ownedTokenAccounts) + set((state) => { state.tokenAccounts = ownedTokenAccounts }) @@ -144,7 +165,7 @@ const useWalletStore = create((set, get) => ({ }) } }, - async fetchVault() { + async fetchUsdcVault() { const connection = get().connection.current const pool = get().pool const set = get().set @@ -155,24 +176,124 @@ const useWalletStore = create((set, get) => ({ connection, pool.poolUsdc ) - console.log('fetchVault', vault) + console.log('fetchUsdcVault', vault) set((state) => { - state.vault = vault + state.usdcVault = vault }) }, - async fetchVaultMint() { + async fetchMints() { const connection = get().connection.current - const vault = get().vault + const pool = get().pool + const mangoVault = get().mangoVault + const usdcVault = get().usdcVault const set = get().set - const { account: mint } = await getMint(connection, vault.mint) - console.log('fetchVaultMint', mint) + const mintKeys = [mangoVault.mint, usdcVault.mint, pool.redeemableMint] + const mints = await Promise.all( + mintKeys.map((pk) => getMint(connection, pk)) + ) + console.log('fetchMints', mints) set((state) => { - state.mints[vault.mint.toBase58()] = mint + for (const pa of mints) { + state.mints[pa.publicKey.toBase58()] = pa.account + console.log('mint', pa.publicKey.toBase58(), pa.account) + } }) }, + async submitContribution(amount: number) { + console.log('submitContribution', amount) + + const actions = get().actions + await actions.fetchWalletTokenAccounts() + + const { + program, + provider, + pool, + tokenAccounts, + mints, + usdcVault, + current: wallet, + connection: { current: connection }, + } = get() + const usdcDecimals = mints[usdcVault.mint.toBase58()].decimals + const redeemable = findLargestBalanceAccountForMint( + mints, + tokenAccounts, + pool.redeemableMint + ) + const usdc = findLargestBalanceAccountForMint( + mints, + tokenAccounts, + usdcVault.mint + ) + + const difference = amount - (redeemable?.balance || 0) + const [poolSigner] = await anchor.web3.PublicKey.findProgramAddress( + [pool.watermelonMint.toBuffer()], + program.programId + ) + + if (difference > 0) { + const depositAmount = new anchor.BN( + difference * Math.pow(10, usdcDecimals) + ) + console.log(depositAmount.toString(), 'exchangeUsdcForReemable') + + let redeemableAccPk = redeemable?.account?.publicKey + const transaction = new Transaction() + if (!redeemable) { + const [ins, pk] = await createAssociatedTokenAccount( + wallet.publicKey, + wallet.publicKey, + pool.redeemableMint + ) + transaction.add(ins) + redeemableAccPk = pk + } + transaction.add( + program.instruction.exchangeUsdcForRedeemable(depositAmount, { + accounts: { + poolAccount: POOL_PK, + poolSigner: poolSigner, + redeemableMint: pool.redeemableMint, + poolUsdc: pool.poolUsdc, + userAuthority: provider.wallet.publicKey, + userUsdc: usdc.account.publicKey, + userRedeemable: redeemableAccPk, + tokenProgram: TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }) + ) + await sendTransaction({ transaction, wallet, connection }) + } else if (difference < 0) { + const withdrawAmount = new anchor.BN( + difference * -1 * Math.pow(10, usdcDecimals) + ) + console.log(withdrawAmount.toString(), 'exchangeRedeemableForUsdc') + await program.rpc.exchangeRedeemableForUsdc(withdrawAmount, { + accounts: { + poolAccount: POOL_PK, + poolSigner: poolSigner, + redeemableMint: pool.redeemableMint, + poolUsdc: pool.poolUsdc, + userAuthority: provider.wallet.publicKey, + userUsdc: usdc.account.publicKey, + userRedeemable: redeemable.account.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + }) + } else { + console.log('difference = 0 no submission needed', difference) + return + } + + await actions.fetchWalletTokenAccounts() + }, }, set: (fn) => set(produce(fn)), })) diff --git a/utils/associated.tsx b/utils/associated.tsx new file mode 100644 index 0000000..9a53580 --- /dev/null +++ b/utils/associated.tsx @@ -0,0 +1,83 @@ +import { + PublicKey, + SystemProgram, + SYSVAR_RENT_PUBKEY, + TransactionInstruction, +} from '@solana/web3.js' +import { TOKEN_PROGRAM_ID } from '@solana/spl-token' + +const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID: PublicKey = new PublicKey( + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +) + +export async function findAssociatedTokenAddress( + walletAddress: PublicKey, + tokenMintAddress: PublicKey +): Promise { + return ( + await PublicKey.findProgramAddress( + [ + walletAddress.toBuffer(), + TOKEN_PROGRAM_ID.toBuffer(), + tokenMintAddress.toBuffer(), + ], + SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID + ) + )[0] +} + +export async function createAssociatedTokenAccount( + fundingAddress: PublicKey, + walletAddress: PublicKey, + splTokenMintAddress: PublicKey +): Promise<[TransactionInstruction, PublicKey]> { + const associatedTokenAddress = await findAssociatedTokenAddress( + walletAddress, + splTokenMintAddress + ) + const keys = [ + { + pubkey: fundingAddress, + isSigner: true, + isWritable: true, + }, + { + pubkey: associatedTokenAddress, + isSigner: false, + isWritable: true, + }, + { + pubkey: walletAddress, + isSigner: false, + isWritable: false, + }, + { + pubkey: splTokenMintAddress, + isSigner: false, + isWritable: false, + }, + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { + pubkey: TOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + { + pubkey: SYSVAR_RENT_PUBKEY, + isSigner: false, + isWritable: false, + }, + ] + return [ + new TransactionInstruction({ + keys, + programId: SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, + data: Buffer.from([]), + }), + associatedTokenAddress, + ] +} diff --git a/utils/index.tsx b/utils/index.tsx deleted file mode 100644 index 3c786d3..0000000 --- a/utils/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import BN from 'bn.js' -import useWalletStore from '../stores/useWalletStore' - -function fixedPointToNumber(value: BN, decimals: number) { - const divisor = new BN(10).pow(new BN(decimals)) - const quotient = value.div(divisor) - const remainder = value.mod(divisor) - return quotient.toNumber() + remainder.toNumber() / divisor.toNumber() -} - -export function getUsdcBalance() { - const { tokenAccounts, mints, vault } = useWalletStore((state) => state) - - if (!vault) return 0 - - const calculateBalance = (a) => { - const mint = mints[a.account?.mint?.toBase58()] - return mint ? fixedPointToNumber(a.account.amount, mint.decimals) : 0 - } - - const usdcAddress = vault.mint.toBase58() - - const usdcAccount = tokenAccounts.filter( - (a) => a.account.mint.toBase58() === usdcAddress - ) - - const usdcBalance = usdcAccount.map((a) => calculateBalance(a)) - - return usdcBalance.length ? usdcBalance[0] : 0 -} diff --git a/utils/send.tsx b/utils/send.tsx new file mode 100644 index 0000000..826bb36 --- /dev/null +++ b/utils/send.tsx @@ -0,0 +1,292 @@ +import { notify } from './notifications' +import { + Account, + AccountInfo, + Commitment, + Connection, + PublicKey, + RpcResponseAndContext, + SimulatedTransactionResponse, + Transaction, + TransactionSignature, +} from '@solana/web3.js' +import Wallet from '@project-serum/sol-wallet-adapter' +import { Buffer } from 'buffer' +import assert from 'assert' +import { struct } from 'superstruct' + +class TransactionError extends Error { + public txid: string + constructor(message: string, txid?: string) { + super(message) + this.txid = txid + } +} + +export async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function getUnixTs() { + return new Date().getTime() / 1000 +} + +const DEFAULT_TIMEOUT = 30000 + +export async function sendTransaction({ + transaction, + wallet, + signers = [], + connection, + sendingMessage = 'Sending transaction...', + successMessage = 'Transaction confirmed', + timeout = DEFAULT_TIMEOUT, +}: { + transaction: Transaction + wallet: Wallet + signers?: Array + connection: Connection + sendingMessage?: string + successMessage?: string + timeout?: number +}) { + const signedTransaction = await signTransaction({ + transaction, + wallet, + signers, + connection, + }) + return await sendSignedTransaction({ + signedTransaction, + connection, + sendingMessage, + successMessage, + timeout, + }) +} + +export async function signTransaction({ + transaction, + wallet, + signers = [], + connection, +}: { + transaction: Transaction + wallet: Wallet + signers?: Array + connection: Connection +}) { + transaction.recentBlockhash = ( + await connection.getRecentBlockhash('max') + ).blockhash + transaction.setSigners(wallet.publicKey, ...signers.map((s) => s.publicKey)) + if (signers.length > 0) { + transaction.partialSign(...signers) + } + return await wallet.signTransaction(transaction) +} + +export async function signTransactions({ + transactionsAndSigners, + wallet, + connection, +}: { + transactionsAndSigners: { + transaction: Transaction + signers?: Array + }[] + wallet: Wallet + connection: Connection +}) { + const blockhash = (await connection.getRecentBlockhash('max')).blockhash + transactionsAndSigners.forEach(({ transaction, signers = [] }) => { + transaction.recentBlockhash = blockhash + transaction.setSigners(wallet.publicKey, ...signers.map((s) => s.publicKey)) + if (signers?.length > 0) { + transaction.partialSign(...signers) + } + }) + return await wallet.signAllTransactions( + transactionsAndSigners.map(({ transaction }) => transaction) + ) +} + +export async function sendSignedTransaction({ + signedTransaction, + connection, + sendingMessage = 'Sending transaction...', + successMessage = 'Transaction confirmed', + timeout = DEFAULT_TIMEOUT, +}: { + signedTransaction: Transaction + connection: Connection + sendingMessage?: string + successMessage?: string + timeout?: number +}): Promise { + const rawTransaction = signedTransaction.serialize() + const startTime = getUnixTs() + notify({ message: sendingMessage }) + const txid: TransactionSignature = await connection.sendRawTransaction( + rawTransaction, + { + skipPreflight: true, + } + ) + + console.log('Started awaiting confirmation for', txid) + + let done = false + ;(async () => { + while (!done && getUnixTs() - startTime < timeout) { + connection.sendRawTransaction(rawTransaction, { + skipPreflight: true, + }) + await sleep(300) + } + })() + try { + await awaitTransactionSignatureConfirmation(txid, timeout, connection) + } catch (err) { + if (err.timeout) { + throw new Error('Timed out awaiting confirmation on transaction') + } + let simulateResult: SimulatedTransactionResponse | null = null + try { + simulateResult = ( + await simulateTransaction(connection, signedTransaction, 'single') + ).value + } catch (e) { + console.log('Error: ', e) + } + if (simulateResult && simulateResult.err) { + if (simulateResult.logs) { + for (let i = simulateResult.logs.length - 1; i >= 0; --i) { + const line = simulateResult.logs[i] + if (line.startsWith('Program log: ')) { + throw new TransactionError( + 'Transaction failed: ' + line.slice('Program log: '.length), + txid + ) + } + } + } + throw new TransactionError(JSON.stringify(simulateResult.err), txid) + } + throw new TransactionError('Transaction failed', txid) + } finally { + done = true + } + notify({ message: successMessage, type: 'success', txid }) + + console.log('Latency', txid, getUnixTs() - startTime) + return txid +} + +async function awaitTransactionSignatureConfirmation( + txid: TransactionSignature, + timeout: number, + connection: Connection +) { + let done = false + const result = await new Promise((resolve, reject) => { + // eslint-disable-next-line + ;(async () => { + setTimeout(() => { + if (done) { + return + } + done = true + console.log('Timed out for txid', txid) + reject({ timeout: true }) + }, timeout) + try { + connection.onSignature( + txid, + (result) => { + console.log('WS confirmed', txid, result) + done = true + if (result.err) { + reject(result.err) + } else { + resolve(result) + } + }, + connection.commitment + ) + console.log('Set up WS connection', txid) + } catch (e) { + done = true + console.log('WS error in setup', txid, e) + } + while (!done) { + // eslint-disable-next-line + ;(async () => { + try { + const signatureStatuses = await connection.getSignatureStatuses([ + txid, + ]) + const result = signatureStatuses && signatureStatuses.value[0] + if (!done) { + if (!result) { + // console.log('REST null result for', txid, result); + } else if (result.err) { + console.log('REST error for', txid, result) + done = true + reject(result.err) + } + // @ts-ignore + else if ( + !( + result.confirmations || + result.confirmationStatus === 'confirmed' || + result.confirmationStatus === 'finalized' + ) + ) { + console.log('REST not confirmed', txid, result) + } else { + console.log('REST confirmed', txid, result) + done = true + resolve(result) + } + } + } catch (e) { + if (!done) { + console.log('REST connection error: txid', txid, e) + } + } + })() + await sleep(300) + } + })() + }) + done = true + return result +} + +/** Copy of Connection.simulateTransaction that takes a commitment parameter. */ +async function simulateTransaction( + connection: Connection, + transaction: Transaction, + commitment: Commitment +): Promise> { + // @ts-ignore + transaction.recentBlockhash = await connection._recentBlockhash( + // @ts-ignore + connection._disableBlockhashCaching + ) + + const signData = transaction.serializeMessage() + // @ts-ignore + const wireTransaction = transaction._serialize(signData) + const encodedTransaction = wireTransaction.toString('base64') + const config: any = { encoding: 'base64', commitment } + const args = [encodedTransaction, config] + + // @ts-ignore + const res = await connection._rpcRequest('simulateTransaction', args) + if (res.error) { + throw new Error('failed to simulate transaction: ' + res.error.message) + } + return res.result +}