diff --git a/packages/app-extension/src/components/Unlocked/Balances/TokensWidget/Solana/index.tsx b/packages/app-extension/src/components/Unlocked/Balances/TokensWidget/Solana/index.tsx index a90bd170a..e650dc7a0 100644 --- a/packages/app-extension/src/components/Unlocked/Balances/TokensWidget/Solana/index.tsx +++ b/packages/app-extension/src/components/Unlocked/Balances/TokensWidget/Solana/index.tsx @@ -1,34 +1,13 @@ -import { useState } from "react"; -import { findMintManagerId } from "@cardinal/creator-standard"; -import { programs, tryGetAccount } from "@cardinal/token-manager"; -import type { RawMintString } from "@coral-xyz/common"; -import { - Blockchain, - confirmTransaction, - getLogger, - metadataAddress, - SOL_NATIVE_MINT, - Solana, -} from "@coral-xyz/common"; +import { Blockchain } from "@coral-xyz/common"; import { PrimaryButton, UserIcon } from "@coral-xyz/react-common"; import { useActiveWallet, useAvatarUrl, useSolanaCtx, - useSolanaTokenMint, + useSolanaTransaction, } from "@coral-xyz/recoil"; import { styles, useCustomTheme } from "@coral-xyz/themes"; -import { - findMintStatePk, - MintState, -} from "@magiceden-oss/open_creator_protocol"; -import { - Metadata, - TokenStandard, -} from "@metaplex-foundation/mpl-token-metadata"; import { Typography } from "@mui/material"; -import type { AccountInfo, Connection } from "@solana/web3.js"; -import { PublicKey } from "@solana/web3.js"; import type { BigNumber } from "ethers"; import { CopyablePublicKey } from "../../../../common/CopyablePublicKey"; @@ -36,8 +15,6 @@ import { SettingsList } from "../../../../common/Settings/List"; import { TokenAmountHeader } from "../../../../common/TokenAmountHeader"; import { Error, Sending } from "../Send"; -const logger = getLogger("send-solana-confirmation-card"); - const useStyles = styles((theme) => ({ confirmTableListItem: { backgroundColor: `${theme.custom.colors.approveTransactionTableBackground} !important`, @@ -72,120 +49,15 @@ export function SendSolanaConfirmationCard({ onComplete?: (txSig?: any) => void; onViewBalances?: () => void; }) { - const [txSignature, setTxSignature] = useState(null); - const solanaCtx = useSolanaCtx(); - const [error, setError] = useState( - "Error 422. Transaction time out. Runtime error. Reticulating splines." - ); - const [cardType, setCardType] = useState< - "confirm" | "sending" | "complete" | "error" - >("confirm"); - const mintInfo = useSolanaTokenMint({ - publicKey: solanaCtx.walletPublicKey.toString(), - tokenAddress: token.address, + const { txSignature, onConfirm, cardType, error } = useSolanaTransaction({ + token, + destinationAddress, + amount, + onComplete: (txid) => { + onComplete?.(txid); + }, }); - const onConfirm = async () => { - setCardType("sending"); - // - // Send the tx. - // - let txSig; - - try { - const mintId = new PublicKey(token.mint?.toString() as string); - if (token.mint === SOL_NATIVE_MINT.toString()) { - txSig = await Solana.transferSol(solanaCtx, { - source: solanaCtx.walletPublicKey, - destination: new PublicKey(destinationAddress), - amount: amount.toNumber(), - }); - } else if ( - await isProgrammableNftToken( - solanaCtx.connection, - token.mint?.toString() as string - ) - ) { - txSig = await Solana.transferProgrammableNft(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - source: new PublicKey(token.address), - }); - } - // Use an else here to avoid an extra request if we are transferring sol native mints. - else { - const ocpMintState = await isOpenCreatorProtocol( - solanaCtx.connection, - mintId, - mintInfo - ); - if (ocpMintState !== null) { - txSig = await Solana.transferOpenCreatorProtocol( - solanaCtx, - { - destination: new PublicKey(destinationAddress), - amount: amount.toNumber(), - mint: new PublicKey(token.mint!), - }, - ocpMintState - ); - } else if (isCreatorStandardToken(mintId, mintInfo)) { - txSig = await Solana.transferCreatorStandardToken(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - }); - } else if ( - await isCardinalWrappedToken(solanaCtx.connection, mintId, mintInfo) - ) { - txSig = await Solana.transferCardinalManagedToken(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - }); - } else { - txSig = await Solana.transferToken(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - }); - } - } - } catch (err: any) { - logger.error("solana transaction failed", err); - setError(err.toString()); - setCardType("error"); - return; - } - - setTxSignature(txSig); - - // - // Confirm the tx. - // - try { - await confirmTransaction( - solanaCtx.connection, - txSig, - solanaCtx.commitment !== "confirmed" && - solanaCtx.commitment !== "finalized" - ? "confirmed" - : solanaCtx.commitment - ); - setCardType("complete"); - if (onComplete) onComplete(txSig); - } catch (err: any) { - logger.error("unable to confirm", err); - setError(err.toString()); - setCardType("error"); - } - }; - return ( <> {cardType === "confirm" ? ( @@ -367,87 +239,3 @@ const ConfirmSendSolanaTable: React.FC<{ /> ); }; - -export const isCardinalWrappedToken = async ( - connection: Connection, - mintId: PublicKey, - mintInfo: RawMintString -) => { - const mintManagerId = ( - await programs.tokenManager.pda.findMintManagerId(mintId) - )[0]; - if ( - !mintInfo.freezeAuthority || - mintInfo.freezeAuthority !== mintManagerId.toString() - ) { - return false; - } - - // only need network calls to double confirm but the above check is likely sufficient if we assume it was created correctly - const [tokenManagerId] = - await programs.tokenManager.pda.findTokenManagerAddress( - new PublicKey(mintId) - ); - const tokenManagerData = await tryGetAccount(() => - programs.tokenManager.accounts.getTokenManager(connection, tokenManagerId) - ); - if (!tokenManagerData?.parsed) { - return false; - } - try { - programs.transferAuthority.accounts.getTransferAuthority( - connection, - tokenManagerData?.parsed.transferAuthority || new PublicKey("") - ); - return true; - } catch (error) { - console.log("Invalid transfer authority"); - } - return false; -}; - -export const isCreatorStandardToken = ( - mintId: PublicKey, - mintInfo: RawMintString -) => { - const mintManagerId = findMintManagerId(mintId); - // not network calls involved we can assume this token was created properly if the mint and freeze authority match - return ( - mintInfo.freezeAuthority && - mintInfo.mintAuthority && - mintInfo.freezeAuthority === mintManagerId.toString() && - mintInfo.mintAuthority === mintManagerId.toString() - ); -}; - -async function isOpenCreatorProtocol( - connection: Connection, - mintId: PublicKey, - mintInfo: RawMintString -): Promise { - const mintStatePk = findMintStatePk(mintId); - const accountInfo = (await connection.getAccountInfo( - mintStatePk - )) as AccountInfo; - return accountInfo !== null - ? MintState.fromAccountInfo(accountInfo)[0] - : null; -} - -async function isProgrammableNftToken( - connection: Connection, - mintAddress: string -): Promise { - try { - const metadata = await Metadata.fromAccountAddress( - connection, - await metadataAddress(new PublicKey(mintAddress)) - ); - - return metadata.tokenStandard == TokenStandard.ProgrammableNonFungible; - } catch (error) { - // most likely this happens if the metadata account does not exist - console.log(error); - return false; - } -} diff --git a/packages/app-mobile/src/components/BottomDrawerSolanaConfirmation.tsx b/packages/app-mobile/src/components/BottomDrawerSolanaConfirmation.tsx index deed2b60d..14549831d 100644 --- a/packages/app-mobile/src/components/BottomDrawerSolanaConfirmation.tsx +++ b/packages/app-mobile/src/components/BottomDrawerSolanaConfirmation.tsx @@ -1,24 +1,13 @@ -import type { Connection } from "@solana/web3.js"; import type { BigNumber } from "ethers"; -import { useState } from "react"; -import { View, Text } from "react-native"; +import { Text } from "react-native"; -import { programs, tryGetAccount } from "@cardinal/token-manager"; +import { Blockchain, walletAddressDisplay } from "@coral-xyz/common"; import { - Blockchain, - confirmTransaction, - SOL_NATIVE_MINT, - Solana, - walletAddressDisplay, - metadataAddress, -} from "@coral-xyz/common"; -import { useSolanaCtx } from "@coral-xyz/recoil"; -import { - Metadata, - TokenStandard, -} from "@metaplex-foundation/mpl-token-metadata"; -import { PublicKey } from "@solana/web3.js"; + useSolanaCtx, + useSolanaTransaction, + SolTransactionStep, +} from "@coral-xyz/recoil"; import { Error, @@ -30,8 +19,6 @@ import { Margin, PrimaryButton, TokenAmountHeader } from "~components/index"; import { useTheme } from "~hooks/useTheme"; import { SettingsList } from "~screens/Unlocked/Settings/components/SettingsMenuList"; -type Step = "confirm" | "sending" | "complete" | "error"; - export function SendSolanaConfirmationCard({ navigation, token, @@ -49,94 +36,16 @@ export function SendSolanaConfirmationCard({ }; destinationAddress: string; amount: BigNumber; - onCompleteStep?: (step: Step) => void; + onCompleteStep?: (step: SolTransactionStep) => void; }): JSX.Element { - const [txSignature, setTxSignature] = useState(null); - const solanaCtx = useSolanaCtx(); - const [error, setError] = useState( - "Error 422. Transaction time out. Runtime error. Reticulating splines." - ); - const [cardType, setCardType] = useState("confirm"); - - const handleChangeStep = (step: Step) => { - setCardType(step); - if (onCompleteStep) { - onCompleteStep(step); - } - }; - - const onConfirm = async () => { - handleChangeStep("sending"); - // - // Send the tx. - // - let txSig; - try { - if (token.mint === SOL_NATIVE_MINT.toString()) { - txSig = await Solana.transferSol(solanaCtx, { - source: solanaCtx.walletPublicKey, - destination: new PublicKey(destinationAddress), - amount: amount.toNumber(), - }); - } else if ( - await isCardinalWrappedToken( - solanaCtx.connection, - token.mint?.toString() as string - ) - ) { - txSig = await Solana.transferCardinalToken(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - }); - } else if ( - await isProgrammableNftToken( - solanaCtx.connection, - token.mint?.toString() as string - ) - ) { - txSig = await Solana.transferProgrammableNft(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - source: new PublicKey(token.address), - }); - } else { - txSig = await Solana.transferToken(solanaCtx, { - destination: new PublicKey(destinationAddress), - mint: new PublicKey(token.mint!), - amount: amount.toNumber(), - decimals: token.decimals, - }); - } - } catch (err: any) { - setError(err.toString()); - handleChangeStep("error"); - return; - } - - setTxSignature(txSig); - - // - // Confirm the tx. - // - try { - await confirmTransaction( - solanaCtx.connection, - txSig, - solanaCtx.commitment !== "confirmed" && - solanaCtx.commitment !== "finalized" - ? "confirmed" - : solanaCtx.commitment - ); - handleChangeStep("complete"); - } catch (err: any) { - setError(err.toString()); - handleChangeStep("error"); - } - }; + const { txSignature, onConfirm, cardType, error } = useSolanaTransaction({ + token, + destinationAddress, + amount, + onComplete: () => { + onCompleteStep?.(cardType); + }, + }); return ( <> @@ -206,7 +115,7 @@ export function ConfirmSendSolana({ ); } -const ConfirmSendSolanaTable: React.FC<{ +export const ConfirmSendSolanaTable: React.FC<{ destinationAddress: string; }> = ({ destinationAddress }) => { const theme = useTheme(); @@ -242,48 +151,3 @@ const ConfirmSendSolanaTable: React.FC<{ /> ); }; - -// TODO(peter) share between mobile/extension -const isCardinalWrappedToken = async ( - connection: Connection, - tokenAddress: string -) => { - const [tokenManagerId] = - await programs.tokenManager.pda.findTokenManagerAddress( - new PublicKey(tokenAddress) - ); - const tokenManagerData = await tryGetAccount(() => - programs.tokenManager.accounts.getTokenManager(connection, tokenManagerId) - ); - if (tokenManagerData?.parsed && tokenManagerData?.parsed.transferAuthority) { - try { - programs.transferAuthority.accounts.getTransferAuthority( - connection, - tokenManagerData?.parsed.transferAuthority - ); - return true; - } catch (error) { - console.error(error); - console.log("Invalid transfer authority"); - } - } - return false; -}; - -const isProgrammableNftToken = async ( - connection: Connection, - mintAddress: string -) => { - try { - const metadata = await Metadata.fromAccountAddress( - connection, - await metadataAddress(new PublicKey(mintAddress)) - ); - - return metadata.tokenStandard == TokenStandard.ProgrammableNonFungible; - } catch (error) { - // most likely this happens if the metadata account does not exist - console.log(error); - return false; - } -}; diff --git a/packages/common/src/solana/index.ts b/packages/common/src/solana/index.ts index a85fe9f23..c7125ded7 100644 --- a/packages/common/src/solana/index.ts +++ b/packages/common/src/solana/index.ts @@ -71,6 +71,7 @@ export * from "./explorer"; export * from "./programs"; export * from "./provider"; export * from "./rpc-helpers"; +export * from "./send-helpers"; export * from "./transaction-helpers"; export * from "./types"; export * from "./wallet-adapter"; diff --git a/packages/common/src/solana/send-helpers.ts b/packages/common/src/solana/send-helpers.ts new file mode 100644 index 000000000..818167f29 --- /dev/null +++ b/packages/common/src/solana/send-helpers.ts @@ -0,0 +1,103 @@ +// Used in Sending NFTs and Tokens in app-extension and app-mobile +// This was all copied over so it can be shared. +// do not blame peter if this doesn't work, he closed his eyes and press cmd + v + +import { findMintManagerId } from "@cardinal/creator-standard"; +import { programs, tryGetAccount } from "@cardinal/token-manager"; +import { + findMintStatePk, + MintState, +} from "@magiceden-oss/open_creator_protocol"; +import { + Metadata, + TokenStandard, +} from "@metaplex-foundation/mpl-token-metadata"; +import type { AccountInfo, Connection } from "@solana/web3.js"; +import { PublicKey } from "@solana/web3.js"; + +import { metadataAddress } from "./programs/token"; +import type { RawMintString } from "./types"; + +export const isCardinalWrappedToken = async ( + connection: Connection, + mintId: PublicKey, + mintInfo: RawMintString +) => { + const mintManagerId = ( + await programs.tokenManager.pda.findMintManagerId(mintId) + )[0]; + if ( + !mintInfo.freezeAuthority || + mintInfo.freezeAuthority !== mintManagerId.toString() + ) { + return false; + } + + // only need network calls to double confirm but the above check is likely sufficient if we assume it was created correctly + const [tokenManagerId] = + await programs.tokenManager.pda.findTokenManagerAddress( + new PublicKey(mintId) + ); + const tokenManagerData = await tryGetAccount(() => + programs.tokenManager.accounts.getTokenManager(connection, tokenManagerId) + ); + if (!tokenManagerData?.parsed) { + return false; + } + try { + await programs.transferAuthority.accounts.getTransferAuthority( + connection, + tokenManagerData?.parsed.transferAuthority || new PublicKey("") + ); + return true; + } catch (error) { + console.log("Invalid transfer authority"); + } + return false; +}; + +export const isCreatorStandardToken = ( + mintId: PublicKey, + mintInfo: RawMintString +) => { + const mintManagerId = findMintManagerId(mintId); + // not network calls involved we can assume this token was created properly if the mint and freeze authority match + return ( + mintInfo.freezeAuthority && + mintInfo.mintAuthority && + mintInfo.freezeAuthority === mintManagerId.toString() && + mintInfo.mintAuthority === mintManagerId.toString() + ); +}; + +export async function isOpenCreatorProtocol( + connection: Connection, + mintId: PublicKey, + mintInfo: RawMintString +): Promise { + const mintStatePk = findMintStatePk(mintId); + const accountInfo = (await connection.getAccountInfo( + mintStatePk + )) as AccountInfo; + return accountInfo !== null + ? MintState.fromAccountInfo(accountInfo)[0] + : null; +} + +export async function isProgrammableNftToken( + connection: Connection, + mintAddress: string +): Promise { + try { + const metadata = await Metadata.fromAccountAddress( + connection, + await metadataAddress(new PublicKey(mintAddress)) + ); + + return metadata.tokenStandard == TokenStandard.ProgrammableNonFungible; + } catch (error) { + // most likely this happens if the metadata account does not exist + console.log(error); + return false; + } +} diff --git a/packages/recoil/src/hooks/solana/index.tsx b/packages/recoil/src/hooks/solana/index.tsx index f60c83b67..0ed3fdc0a 100644 --- a/packages/recoil/src/hooks/solana/index.tsx +++ b/packages/recoil/src/hooks/solana/index.tsx @@ -10,6 +10,7 @@ export * from "./useRecentTransactions"; export * from "./useSolanaCommitment"; export * from "./useSolanaConnection"; export * from "./useSolanaExplorer"; +export * from "./useSolanaTransaction"; export * from "./useSplTokenRegistry"; export function useSolanaTokenMint({ diff --git a/packages/recoil/src/hooks/solana/useSolanaTransaction.tsx b/packages/recoil/src/hooks/solana/useSolanaTransaction.tsx new file mode 100644 index 000000000..c79fbffc9 --- /dev/null +++ b/packages/recoil/src/hooks/solana/useSolanaTransaction.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { + confirmTransaction, + getLogger, + isCardinalWrappedToken, + isCreatorStandardToken, + isOpenCreatorProtocol, + isProgrammableNftToken, + SOL_NATIVE_MINT, + Solana, +} from "@coral-xyz/common"; +import { PublicKey } from "@solana/web3.js"; +import type { BigNumber } from "ethers"; + +import { useSolanaTokenMint } from "./index"; +import { useSolanaCtx } from "./useSolanaConnection"; + +type Token = any; // TODO; +export type SolTransactionStep = "confirm" | "sending" | "complete" | "error"; + +const logger = getLogger("send-solana-transaction"); + +export function useSolanaTransaction({ + token, + destinationAddress, + amount, + onComplete, +}: { + token: Token; + destinationAddress: string; + amount: BigNumber; + onComplete: (txId: string) => void; +}): { + txSignature: string | null; + onConfirm: () => Promise; + cardType: SolTransactionStep; + error: string; +} { + const [txSignature, setTxSignature] = useState(null); + const solanaCtx = useSolanaCtx(); + const [error, setError] = useState( + "Error 422. Transaction time out. Runtime error. Reticulating splines." + ); + const [cardType, setCardType] = useState("confirm"); + const mintInfo = useSolanaTokenMint({ + publicKey: solanaCtx.walletPublicKey.toString(), + tokenAddress: token.address, + }); + + const onConfirm = async () => { + setCardType("sending"); + // + // Send the tx. + // + let txSig; + + try { + const mintId = new PublicKey(token.mint?.toString() as string); + if (token.mint === SOL_NATIVE_MINT.toString()) { + txSig = await Solana.transferSol(solanaCtx, { + source: solanaCtx.walletPublicKey, + destination: new PublicKey(destinationAddress), + amount: amount.toNumber(), + }); + } else if ( + await isProgrammableNftToken( + solanaCtx.connection, + token.mint?.toString() as string + ) + ) { + txSig = await Solana.transferProgrammableNft(solanaCtx, { + destination: new PublicKey(destinationAddress), + mint: new PublicKey(token.mint!), + amount: amount.toNumber(), + decimals: token.decimals, + source: new PublicKey(token.address), + }); + } + // Use an else here to avoid an extra request if we are transferring sol native mints. + else { + const ocpMintState = await isOpenCreatorProtocol( + solanaCtx.connection, + mintId, + mintInfo + ); + if (ocpMintState !== null) { + txSig = await Solana.transferOpenCreatorProtocol( + solanaCtx, + { + destination: new PublicKey(destinationAddress), + amount: amount.toNumber(), + mint: new PublicKey(token.mint!), + }, + ocpMintState + ); + } else if (isCreatorStandardToken(mintId, mintInfo)) { + txSig = await Solana.transferCreatorStandardToken(solanaCtx, { + destination: new PublicKey(destinationAddress), + mint: new PublicKey(token.mint!), + amount: amount.toNumber(), + decimals: token.decimals, + }); + } else if ( + await isCardinalWrappedToken(solanaCtx.connection, mintId, mintInfo) + ) { + txSig = await Solana.transferCardinalManagedToken(solanaCtx, { + destination: new PublicKey(destinationAddress), + mint: new PublicKey(token.mint!), + amount: amount.toNumber(), + decimals: token.decimals, + }); + } else { + txSig = await Solana.transferToken(solanaCtx, { + destination: new PublicKey(destinationAddress), + mint: new PublicKey(token.mint!), + amount: amount.toNumber(), + decimals: token.decimals, + }); + } + } + } catch (err: any) { + logger.error("solana transaction failed", err); + setError(err.toString()); + setCardType("error"); + return; + } + + setTxSignature(txSig); + + // + // Confirm the tx. + // + try { + await confirmTransaction( + solanaCtx.connection, + txSig, + solanaCtx.commitment !== "confirmed" && + solanaCtx.commitment !== "finalized" + ? "confirmed" + : solanaCtx.commitment + ); + setCardType("complete"); + if (onComplete) onComplete(txSig); + } catch (err: any) { + logger.error("unable to confirm", err); + setError(err.toString()); + setCardType("error"); + } + }; + + return { txSignature, onConfirm, cardType, error }; +}