From 21ff312bd22bc14446bc9ae355c5d978ea456e97 Mon Sep 17 00:00:00 2001 From: Chiu Date: Fri, 21 Oct 2022 14:37:11 +0800 Subject: [PATCH] chore(ui): evm to solana swap --- apps/ui/package.json | 2 +- ...UsdOnSolana.tsx => ClaimTokenOnSolana.tsx} | 15 +- .../buildEuiStepsForInteraction.tsx | 30 +- .../src/fixtures/swim/interactionStateV2.ts | 15 +- .../useCreateInteractionStateV2.ts | 9 +- ...ChainEvmToSolanaSwapInteractionMutation.ts | 311 ++++++++++++++++-- apps/ui/src/models/swim/interactionStateV2.ts | 17 +- packages/solana/package.json | 4 +- packages/solana/src/client.ts | 258 +++++++++------ packages/solana/src/getAccounts.ts | 296 +++++++++++++++++ packages/solana/src/protocol.ts | 2 + yarn.lock | 4 +- 12 files changed, 785 insertions(+), 178 deletions(-) rename apps/ui/src/components/molecules/{ClaimSwimUsdOnSolana.tsx => ClaimTokenOnSolana.tsx} (78%) create mode 100644 packages/solana/src/getAccounts.ts diff --git a/apps/ui/package.json b/apps/ui/package.json index a87d9fba5..3629cb6b1 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -55,7 +55,7 @@ "@swim-io/evm": "^0.40.0", "@swim-io/evm-contracts": "^0.40.0", "@swim-io/pool-math": "^0.40.0", - "@swim-io/solana": "^0.40.0", + "@swim-io/solana": "workspace:^", "@swim-io/solana-contracts": "^0.40.0", "@swim-io/token-projects": "^0.40.0", "@swim-io/utils": "^0.40.0", diff --git a/apps/ui/src/components/molecules/ClaimSwimUsdOnSolana.tsx b/apps/ui/src/components/molecules/ClaimTokenOnSolana.tsx similarity index 78% rename from apps/ui/src/components/molecules/ClaimSwimUsdOnSolana.tsx rename to apps/ui/src/components/molecules/ClaimTokenOnSolana.tsx index 3590727b0..aec22fa98 100644 --- a/apps/ui/src/components/molecules/ClaimSwimUsdOnSolana.tsx +++ b/apps/ui/src/components/molecules/ClaimTokenOnSolana.tsx @@ -1,36 +1,31 @@ import { EuiLoadingSpinner, EuiText } from "@elastic/eui"; +import type { TokenConfig } from "@swim-io/core/types"; import { SOLANA_ECOSYSTEM_ID } from "@swim-io/solana"; import { TOKEN_PROJECTS_BY_ID } from "@swim-io/token-projects"; import type { VFC } from "react"; import { useTranslation } from "react-i18next"; -import { useSwimUsd } from "../../hooks"; - import { TxEcosystemList } from "./TxList"; interface Props { readonly isLoading: boolean; + readonly tokenConfig: TokenConfig; readonly transactions: readonly string[]; } -export const ClaimSwimUsdOnSolana: VFC = ({ +export const ClaimTokenOnSolana: VFC = ({ isLoading, + tokenConfig, transactions, }) => { const { t } = useTranslation(); - const swimUsd = useSwimUsd(); - - if (swimUsd === null) { - return null; - } - return ( {isLoading && } {t("recent_interactions.claim_token_on_solana", { - tokenName: TOKEN_PROJECTS_BY_ID[swimUsd.projectId].displayName, + tokenName: TOKEN_PROJECTS_BY_ID[tokenConfig.projectId].displayName, })} diff --git a/apps/ui/src/components/molecules/InteractionStateComponentV2/buildEuiStepsForInteraction.tsx b/apps/ui/src/components/molecules/InteractionStateComponentV2/buildEuiStepsForInteraction.tsx index 41fae87d0..699cc821d 100644 --- a/apps/ui/src/components/molecules/InteractionStateComponentV2/buildEuiStepsForInteraction.tsx +++ b/apps/ui/src/components/molecules/InteractionStateComponentV2/buildEuiStepsForInteraction.tsx @@ -25,7 +25,7 @@ import { isTargetChainOperationCompleted, } from "../../../models"; import { AddTransfer } from "../AddTransfer"; -import { ClaimSwimUsdOnSolana } from "../ClaimSwimUsdOnSolana"; +import { ClaimTokenOnSolana } from "../ClaimTokenOnSolana"; import { RemoveTransfer } from "../RemoveTransfer"; import { SwapFromSwimUsd } from "../SwapFromSwimUsd"; import { SwapToSwimUsd } from "../SwapToSwimUsd"; @@ -314,9 +314,10 @@ const buildClaimTokenOnSolanaStep = ( interactionStatus: InteractionStatusV2, ): EuiStepProps => { const { - claimTokenOnSolanaTxId, - postVaaOnSolanaTxIds, - swapFromSwimUsdTxId, + verifySignatureTxId, + postVaaOnSolanaTxId, + completeNativeWithPayloadTxId, + processSwimPayloadTxId, interaction: { params: { toTokenData }, }, @@ -333,21 +334,18 @@ const buildClaimTokenOnSolanaStep = ( status, children: ( <> - - {!isSwimUsd(toTokenData.tokenConfig) && ( - - )} ), }; diff --git a/apps/ui/src/fixtures/swim/interactionStateV2.ts b/apps/ui/src/fixtures/swim/interactionStateV2.ts index 45e51589f..7df06ce73 100644 --- a/apps/ui/src/fixtures/swim/interactionStateV2.ts +++ b/apps/ui/src/fixtures/swim/interactionStateV2.ts @@ -370,10 +370,11 @@ export const CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_INIT: CrossChainEv requiredSplTokenAccounts: SPL_TOKEN_ACCOUNTS_INIT, approvalTxIds: [], crossChainInitiateTxId: null, - signatureSetAddress: null, - postVaaOnSolanaTxIds: [], - claimTokenOnSolanaTxId: null, - swapFromSwimUsdTxId: null, + auxiliarySignerPublicKey: null, + verifySignatureTxId: null, + postVaaOnSolanaTxId: null, + completeNativeWithPayloadTxId: null, + processSwimPayloadTxId: null, }; export const CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_EXISTING_SPL_TOKEN_ACCOUNTS: CrossChainEvmToSolanaSwapInteractionState = @@ -398,16 +399,16 @@ export const CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_SWAP_AND_TRANSFER_ export const CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_POST_VAA_COMPLETED: CrossChainEvmToSolanaSwapInteractionState = { ...CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_SWAP_AND_TRANSFER_COMPLETED, - postVaaOnSolanaTxIds: [ + verifySignatureTxId: "53r98E5EiffkmJ6WVA2VKmq78LVCT4zcRVxo76EWoUFiNpdxbno7UVeUT6oQgsVM3xeU99mQmnUjFVscz7PC1gK8", + postVaaOnSolanaTxId: "53r98E5EiffkmJ6WVA2VKmq78LVCT4zcRVxo76EWoUFiNpdxbno7UVeUT6oQgsVM3xeU99mQmnUjFVscz7PC1gK9", - ], }; export const CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_COMPLETED: CrossChainEvmToSolanaSwapInteractionState = { ...CROSS_CHAIN_EVM_TO_SOLANA_SWAP_INTERACTION_STATE_POST_VAA_COMPLETED, - claimTokenOnSolanaTxId: + completeNativeWithPayloadTxId: "53r98E5EiffkmJ6WVA2VKmq78LVCT4zcRVxo76EWoUFiNpdxbno7UVeUT6oQgsVM3xeU99mQmnUjFVscz7PC1gK8", }; diff --git a/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts b/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts index 09ad899a4..8feca3c3e 100644 --- a/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts +++ b/apps/ui/src/hooks/interaction/useCreateInteractionStateV2.ts @@ -212,10 +212,11 @@ const createSwapInteractionState = ( requiredSplTokenAccounts, approvalTxIds: [], crossChainInitiateTxId: null, - signatureSetAddress: null, - postVaaOnSolanaTxIds: [], - claimTokenOnSolanaTxId: null, - swapFromSwimUsdTxId: null, + auxiliarySignerPublicKey: null, + verifySignatureTxId: null, + postVaaOnSolanaTxId: null, + completeNativeWithPayloadTxId: null, + processSwimPayloadTxId: null, }; } }; diff --git a/apps/ui/src/hooks/interaction/useCrossChainEvmToSolanaSwapInteractionMutation.ts b/apps/ui/src/hooks/interaction/useCrossChainEvmToSolanaSwapInteractionMutation.ts index 04dec0977..c800743c6 100644 --- a/apps/ui/src/hooks/interaction/useCrossChainEvmToSolanaSwapInteractionMutation.ts +++ b/apps/ui/src/hooks/interaction/useCrossChainEvmToSolanaSwapInteractionMutation.ts @@ -1,42 +1,299 @@ -import { useMutation } from "react-query"; +import type { ChainId } from "@certusone/wormhole-sdk"; +import { + getEmitterAddressEth, + parseSequenceFromLogEth, +} from "@certusone/wormhole-sdk"; +import { Keypair } from "@solana/web3.js"; +import { getTokenDetails } from "@swim-io/core"; +import { EVM_ECOSYSTEMS, isEvmEcosystemId } from "@swim-io/evm"; +import { Routing__factory } from "@swim-io/evm-contracts"; +import { + SOLANA_ECOSYSTEM_ID, + SolanaTxType, + findTokenAccountForMint, +} from "@swim-io/solana"; +import { TOKEN_PROJECTS_BY_ID } from "@swim-io/token-projects"; +import { useMutation, useQueryClient } from "react-query"; +import shallow from "zustand/shallow.js"; -import { useInteractionStateV2 } from "../../core/store"; +import { getWormholeRetries } from "../../config"; +import { selectConfig } from "../../core/selectors"; +import { useEnvironment, useInteractionStateV2 } from "../../core/store"; import type { CrossChainEvmToSolanaSwapInteractionState } from "../../models"; -import { InteractionType, SwapType } from "../../models"; +import { + InteractionType, + SwapType, + findOrCreateSplTokenAccount, + getSignedVaaWithRetry, + humanDecimalToAtomicString, +} from "../../models"; +import { useWallets } from "../crossEcosystem"; +import { useGetEvmClient } from "../evm"; +import { useSolanaClient, useUserSolanaTokenAccountsQuery } from "../solana"; +import { useSwimUsd } from "../swim"; export const useCrossChainEvmToSolanaSwapInteractionMutation = () => { + const queryClient = useQueryClient(); + const { data: existingSplTokenAccounts = [] } = + useUserSolanaTokenAccountsQuery(); const { updateInteractionState } = useInteractionStateV2(); + const wallets = useWallets(); + const solanaClient = useSolanaClient(); + const getEvmClient = useGetEvmClient(); + const { env } = useEnvironment(); + const config = useEnvironment(selectConfig, shallow); + const { ecosystems, wormhole } = config; + const swimUsd = useSwimUsd(); + return useMutation( - // eslint-disable-next-line @typescript-eslint/require-await async (interactionState: CrossChainEvmToSolanaSwapInteractionState) => { - const { interaction } = interactionState; - - // TODO: Handle cross chain evm to solana swap, swapAndTransfer - - updateInteractionState(interaction.id, (draft) => { - if (draft.interactionType !== InteractionType.SwapV2) { - throw new Error("Interaction type mismatch"); + if (swimUsd === null) { + throw new Error("SwimUsd not found"); + } + if (wormhole === null) { + throw new Error("No Wormhole RPC configured"); + } + const { interaction, requiredSplTokenAccounts } = interactionState; + const { fromTokenData, toTokenData, firstMinimumOutputAmount } = + interaction.params; + if (firstMinimumOutputAmount === null) { + throw new Error("Missing first minimum output amount"); + } + const fromEcosystem = fromTokenData.ecosystemId; + const toEcosystem = toTokenData.ecosystemId; + if ( + !isEvmEcosystemId(fromEcosystem) || + toEcosystem !== SOLANA_ECOSYSTEM_ID + ) { + throw new Error("Expect ecosystem id"); + } + const fromWallet = wallets[fromEcosystem].wallet; + if ( + fromWallet === null || + fromWallet.address === null || + fromWallet.signer === null + ) { + throw new Error(`${fromEcosystem} wallet not found`); + } + const toWallet = wallets[toEcosystem].wallet; + if ( + toWallet === null || + toWallet.address === null || + toWallet.publicKey === null + ) { + throw new Error(`${toEcosystem} wallet not found`); + } + const fromTokenSpec = fromTokenData.tokenConfig; + const toTokenSpec = toTokenData.tokenConfig; + const fromChainConfig = EVM_ECOSYSTEMS[fromEcosystem].chains[env] ?? null; + if (fromChainConfig === null) { + throw new Error(`${fromEcosystem} chain config not found`); + } + const fromTokenDetails = getTokenDetails( + fromChainConfig, + fromTokenSpec.projectId, + ); + await fromWallet.switchNetwork(fromChainConfig.chainId); + const evmClient = getEvmClient(fromEcosystem); + const fromRouting = Routing__factory.connect( + fromChainConfig.routingContractAddress, + evmClient.provider, + ); + const splTokenAccounts = await Promise.all( + Object.keys(requiredSplTokenAccounts).map(async (mint) => { + const { tokenAccount, creationTxId } = + await findOrCreateSplTokenAccount({ + env: interaction.env, + solanaClient, + wallet: toWallet, + queryClient, + splTokenMintAddress: mint, + splTokenAccounts: existingSplTokenAccounts, + }); + // Update interactionState + if (creationTxId !== null) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.SingleChainSolana + ) { + throw new Error("Interaction type mismatch"); + } + draft.requiredSplTokenAccounts[mint].txId = creationTxId; + }); + } + return tokenAccount; + }), + ); + const swimUsdAccount = findTokenAccountForMint( + swimUsd.nativeDetails.address, + toWallet.address, + splTokenAccounts, + ); + if (swimUsdAccount === null) { + throw new Error("SwimUsd account not found"); + } + const memo = Buffer.from(interaction.id, "hex"); + let crossChainInitiateTxId = interactionState.crossChainInitiateTxId; + if (crossChainInitiateTxId === null) { + const atomicAmount = humanDecimalToAtomicString( + fromTokenData.value, + fromTokenData.tokenConfig, + fromTokenData.ecosystemId, + ); + const approveTxGenerator = evmClient.generateErc20ApproveTxs({ + atomicAmount, + wallet: fromWallet, + mintAddress: fromTokenDetails.address, + spenderAddress: fromChainConfig.routingContractAddress, + }); + for await (const result of approveTxGenerator) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.CrossChainEvmToSolana + ) { + throw new Error("Interaction type mismatch"); + } + draft.approvalTxIds.push(result.tx.id); + }); } - if (draft.swapType !== SwapType.CrossChainEvmToSolana) { - throw new Error("Swap type mismatch"); - } - // TODO: update txId - // draft.swapAndTransferTxId = txId; - }); + const crossChainInitiateRequest = await fromRouting.populateTransaction[ + "crossChainInitiate(address,uint256,uint256,uint16,bytes32,bytes16)" + ]( + fromTokenDetails.address, + atomicAmount, + humanDecimalToAtomicString( + firstMinimumOutputAmount, + swimUsd, + fromEcosystem, + ), + ecosystems[toEcosystem].wormholeChainId, + toWallet.publicKey.toBytes(), + memo, + ); + await fromWallet.switchNetwork(fromChainConfig.chainId); + const crossChainInitiateResponse = + await fromWallet.signer.sendTransaction(crossChainInitiateRequest); + const crossChainInitiateTx = await evmClient.getTx( + crossChainInitiateResponse, + ); + crossChainInitiateTxId = crossChainInitiateTx.id; + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.CrossChainEvmToSolana + ) { + throw new Error("Interaction type mismatch"); + } + draft.crossChainInitiateTxId = crossChainInitiateTx.id; + }); + } + const crossChainInitiateTx = await evmClient.getTx( + crossChainInitiateTxId, + ); + const wormholeSequence = parseSequenceFromLogEth( + crossChainInitiateTx.original, + fromChainConfig.wormhole.bridge, + ); + const { wormholeChainId: emitterChainId } = ecosystems[fromEcosystem]; + const retries = getWormholeRetries(emitterChainId); + const { vaaBytes: signedVaa } = await getSignedVaaWithRetry( + [...wormhole.rpcUrls], + emitterChainId, + getEmitterAddressEth(fromChainConfig.wormhole.portal), + wormholeSequence, + undefined, + undefined, + retries, + ); + if (interactionState.postVaaOnSolanaTxId === null) { + const auxiliarySigner = Keypair.generate(); + const postVaaTxIdsGenerator = + solanaClient.generateCompleteWormholeMessageTxs({ + interactionId: interaction.id, + vaa: signedVaa, + wallet: toWallet, + auxiliarySigner, + }); + for await (const result of postVaaTxIdsGenerator) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.CrossChainEvmToSolana + ) { + throw new Error("Interaction type mismatch"); + } - // TODO: Handle cross chain evm to solana swap, + switch (result.type) { + case SolanaTxType.WormholeVerifySignatures: + draft.verifySignatureTxId = result.tx.id; + break; + case SolanaTxType.WormholePostVaa: + draft.postVaaOnSolanaTxId = result.tx.id; + draft.auxiliarySignerPublicKey = + auxiliarySigner.publicKey.toBase58(); + break; + default: + throw new Error(`Unexpected transaction type: ${result.tx.id}`); + } + }); + } + } + if (interactionState.completeNativeWithPayloadTxId === null) { + const sourceWormholeChainId = EVM_ECOSYSTEMS[fromEcosystem] + .wormholeChainId as ChainId; + const completeNativeWithPayloadGenerator = + solanaClient.generateCompleteNativeWithPayloadTx({ + wallet: toWallet, + interactionId: interaction.id, + sourceChainConfig: fromChainConfig, + sourceWormholeChainId, + signedVaa: Buffer.from(signedVaa), + }); - updateInteractionState(interaction.id, (draft) => { - if (draft.interactionType !== InteractionType.SwapV2) { - throw new Error("Interaction type mismatch"); + for await (const result of completeNativeWithPayloadGenerator) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.CrossChainEvmToSolana + ) { + throw new Error("Interaction type mismatch"); + } + draft.completeNativeWithPayloadTxId = result.tx.id; + }); + } + } + if (interactionState.processSwimPayloadTxId === null) { + const tokenProject = TOKEN_PROJECTS_BY_ID[toTokenSpec.projectId]; + if (tokenProject.tokenNumber === null) { + throw new Error(`Token number for ${tokenProject.symbol} not found`); } - if (draft.swapType !== SwapType.CrossChainEvmToSolana) { - throw new Error("Swap type mismatch"); + const minOutputAmount = humanDecimalToAtomicString( + toTokenData.value, + toTokenData.tokenConfig, + toTokenData.ecosystemId, + ); + const processSwimPayloadGenerator = + solanaClient.generateProcessSwimPayloadTx({ + wallet: toWallet, + interactionId: interaction.id, + signedVaa: Buffer.from(signedVaa), + targetTokenNumber: tokenProject.tokenNumber, + minOutputAmount: minOutputAmount, + }); + for await (const result of processSwimPayloadGenerator) { + updateInteractionState(interaction.id, (draft) => { + if ( + draft.interactionType !== InteractionType.SwapV2 || + draft.swapType !== SwapType.CrossChainEvmToSolana + ) { + throw new Error("Interaction type mismatch"); + } + draft.processSwimPayloadTxId = result.tx.id; + }); } - // TODO: update txId - // draft.postVaaOnSolanaTxIds = txIds; - // draft.claimTokenOnSolanaTxId = txId; - }); + } }, ); }; diff --git a/apps/ui/src/models/swim/interactionStateV2.ts b/apps/ui/src/models/swim/interactionStateV2.ts index 63d9153c2..425f1f6d6 100644 --- a/apps/ui/src/models/swim/interactionStateV2.ts +++ b/apps/ui/src/models/swim/interactionStateV2.ts @@ -4,8 +4,6 @@ import type { SolanaTx } from "@swim-io/solana"; import { SOLANA_ECOSYSTEM_ID } from "@swim-io/solana"; import { isNotNull } from "@swim-io/utils"; -import { isSwimUsd } from "../../config"; - import type { AddInteraction, RemoveExactBurnInteraction, @@ -72,10 +70,11 @@ export interface CrossChainEvmToSolanaSwapInteractionState { readonly requiredSplTokenAccounts: RequiredSplTokenAccounts; readonly approvalTxIds: readonly EvmTx["id"][]; readonly crossChainInitiateTxId: EvmTx["id"] | null; - readonly signatureSetAddress: string | null; - readonly postVaaOnSolanaTxIds: readonly SolanaTx["id"][]; - readonly claimTokenOnSolanaTxId: SolanaTx["id"] | null; - readonly swapFromSwimUsdTxId: SolanaTx["id"] | null; + readonly auxiliarySignerPublicKey: string | null; + readonly verifySignatureTxId: SolanaTx["id"] | null; + readonly postVaaOnSolanaTxId: SolanaTx["id"] | null; + readonly completeNativeWithPayloadTxId: SolanaTx["id"] | null; + readonly processSwimPayloadTxId: SolanaTx["id"] | null; } export interface AddInteractionState { @@ -176,11 +175,7 @@ export const isTargetChainOperationCompleted = ( case SwapType.CrossChainSolanaToEvm: return state.crossChainCompleteTxId !== null; case SwapType.CrossChainEvmToSolana: { - if (isSwimUsd(state.interaction.params.toTokenData.tokenConfig)) { - return state.claimTokenOnSolanaTxId !== null; - } else { - return state.swapFromSwimUsdTxId !== null; - } + return state.processSwimPayloadTxId !== null; } } }; diff --git a/packages/solana/package.json b/packages/solana/package.json index feb2b2570..d59434b69 100644 --- a/packages/solana/package.json +++ b/packages/solana/package.json @@ -38,7 +38,9 @@ "@swim-io/core": "workspace:^", "@swim-io/solana-contracts": "workspace:^", "@swim-io/token-projects": "workspace:^", - "@swim-io/utils": "workspace:^" + "@swim-io/utils": "workspace:^", + "byteify": "^2.0.10", + "keccak256": "^1.0.6" }, "devDependencies": { "@certusone/wormhole-sdk": "^0.6.2", diff --git a/packages/solana/src/client.ts b/packages/solana/src/client.ts index 2f9dfdbaa..df634fbdd 100644 --- a/packages/solana/src/client.ts +++ b/packages/solana/src/client.ts @@ -1,10 +1,8 @@ import type { ChainId } from "@certusone/wormhole-sdk"; import { createVerifySignaturesInstructionsSolana } from "@certusone/wormhole-sdk"; -import type { Accounts } from "@project-serum/anchor"; import { AnchorProvider, Program } from "@project-serum/anchor"; import { createMemoInstruction } from "@solana/spl-memo"; import { - TOKEN_PROGRAM_ID, createAssociatedTokenAccountInstruction, getAssociatedTokenAddress, getAssociatedTokenAddressSync, @@ -24,9 +22,9 @@ import { Keypair, LAMPORTS_PER_SOL, PublicKey, - SYSVAR_CLOCK_PUBKEY, } from "@solana/web3.js"; import type { + ChainConfig, CompletePortalTransferParams, InitiatePortalTransferParams, InitiatePropellerParams, @@ -42,6 +40,12 @@ import { atomicToHuman, chunks, humanToAtomic, sleep } from "@swim-io/utils"; import BN from "bn.js"; import Decimal from "decimal.js"; +import { + createCompleteNativeWithPayloadAccounts, + createProcessSwimPayloadAccounts, + getAddAccounts, + getPropellerTransferAccounts, +} from "./getAccounts"; import type { SolanaChainConfig, SolanaEcosystemId, @@ -369,6 +373,136 @@ export class SolanaClient extends Client< }; } + public async *generateCompleteNativeWithPayloadTx({ + wallet, + interactionId, + sourceWormholeChainId, + sourceChainConfig, + signedVaa, + }: { + readonly wallet: SolanaWalletAdapter; + readonly interactionId: string; + readonly sourceWormholeChainId: ChainId; + readonly sourceChainConfig: ChainConfig; + readonly signedVaa: Buffer; + }): AsyncGenerator< + TxGeneratorResult, + any, + unknown + > { + const walletPublicKey = wallet.publicKey; + if (walletPublicKey === null) { + throw new Error("Missing Solana wallet"); + } + const routingContract = this.getRoutingContract(wallet); + const swimUsdAtaPublicKey = getAssociatedTokenAddressSync( + new PublicKey(this.chainConfig.swimUsdDetails.address), + walletPublicKey, + ); + const accounts = await createCompleteNativeWithPayloadAccounts( + this.chainConfig, + new PublicKey(walletPublicKey), + signedVaa, + swimUsdAtaPublicKey, + sourceWormholeChainId, + sourceChainConfig, + ); + const setComputeUnitLimitIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 900_000, + }); + const txRequest = await routingContract.methods + .completeNativeWithPayload() + .accounts(accounts) + .preInstructions([setComputeUnitLimitIx]) + .postInstructions([createMemoInstruction(interactionId)]) + .transaction(); + // eslint-disable-next-line functional/immutable-data + txRequest.feePayer = walletPublicKey; + const txId = await this.sendAndConfirmTx( + (tx) => wallet.signTransaction(tx), + txRequest, + ); + const tx = await this.getTx(txId); + yield { + tx, + type: SolanaTxType.SwimCompleteNativeWithPayload, + }; + } + + public async *generateProcessSwimPayloadTx({ + wallet, + interactionId, + signedVaa, + targetTokenNumber, + minOutputAmount, + }: { + readonly wallet: SolanaWalletAdapter; + readonly interactionId: string; + readonly signedVaa: Buffer; + readonly targetTokenNumber: number; + readonly minOutputAmount: string; + }): AsyncGenerator< + TxGeneratorResult, + any, + unknown + > { + const [twoPoolConfig] = this.chainConfig.pools; + const walletPublicKey = wallet.publicKey; + if (walletPublicKey === null) { + throw new Error("Missing Solana wallet"); + } + const routingContract = this.getRoutingContract(wallet); + const poolTokenAccounts = [...twoPoolConfig.tokenAccounts.values()].map( + (address) => new PublicKey(address), + ); + const userTokenAccounts = SUPPORTED_TOKEN_PROJECT_IDS.reduce( + (accumulator, tokenProjectId) => { + const { address } = getTokenDetails(this.chainConfig, tokenProjectId); + return { + ...accumulator, + [tokenProjectId]: getAssociatedTokenAddressSync( + new PublicKey(address), + walletPublicKey, + ), + }; + }, + {} as ReadonlyRecord, + ); + const accounts = await createProcessSwimPayloadAccounts( + this.chainConfig, + new PublicKey(walletPublicKey), + signedVaa, + userTokenAccounts[TokenProjectId.SwimUsd], + [ + userTokenAccounts[TokenProjectId.Usdc], + userTokenAccounts[TokenProjectId.Usdt], + ], + poolTokenAccounts, + new PublicKey(twoPoolConfig.governanceFeeAccount), + targetTokenNumber, + ); + const setComputeUnitLimitIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 900_000, + }); + const txRequest = await routingContract.methods + .processSwimPayload(targetTokenNumber, new BN(minOutputAmount)) + .accounts(accounts) + .preInstructions([setComputeUnitLimitIx]) + .postInstructions([createMemoInstruction(interactionId)]) + .transaction(); + // eslint-disable-next-line functional/immutable-data + txRequest.feePayer = walletPublicKey; + const txId = await this.sendAndConfirmTx( + (tx) => wallet.signTransaction(tx), + txRequest, + ); + const tx = await this.getTx(txId); + yield { + tx, + type: SolanaTxType.SwimProcessSwimPayload, + }; + } + public async *generateInitiatePropellerTxs({ wallet, interactionId, @@ -401,19 +535,7 @@ export class SolanaClient extends Client< sourceTokenDetails.decimals, ).toString(); - const anchorProvider = new AnchorProvider( - this.connection, - { - ...wallet, - publicKey: senderPublicKey, - }, - { commitment: "confirmed" }, - ); - const routingContract = new Program( - idl.propeller, - this.chainConfig.routingContractAddress, - anchorProvider, - ); + const routingContract = this.getRoutingContract(wallet); let addOutputAmountAtomic: string | null = null; if (sourceTokenId !== TokenProjectId.SwimUsd) { @@ -806,90 +928,24 @@ export class SolanaClient extends Client< } } - private getAddAccounts( - userSwimUsdAtaPublicKey: PublicKey, - userTokenAccounts: readonly PublicKey[], - auxiliarySigner: PublicKey, - lpMint: PublicKey, - poolTokenAccounts: readonly PublicKey[], - poolGovernanceFeeAccount: PublicKey, - ): Accounts { - return { - propeller: new PublicKey(this.chainConfig.routingContractStateAddress), - tokenProgram: TOKEN_PROGRAM_ID, - poolTokenAccount0: poolTokenAccounts[0], - poolTokenAccount1: poolTokenAccounts[1], - lpMint, - governanceFee: poolGovernanceFeeAccount, - userTransferAuthority: auxiliarySigner, - userTokenAccount0: userTokenAccounts[0], - userTokenAccount1: userTokenAccounts[1], - userLpTokenAccount: userSwimUsdAtaPublicKey, - twoPoolProgram: new PublicKey(this.chainConfig.twoPoolContractAddress), - }; - } - - private async getPropellerTransferAccounts( - walletPublicKey: PublicKey, - swimUsdAtaPublicKey: PublicKey, - auxiliarySigner: PublicKey, - ): Promise { - const bridgePublicKey = new PublicKey(this.chainConfig.wormhole.bridge); - const portalPublicKey = new PublicKey(this.chainConfig.wormhole.portal); - const swimUsdMintPublicKey = new PublicKey( - this.chainConfig.swimUsdDetails.address, - ); - const [wormholeConfig] = await PublicKey.findProgramAddress( - [Buffer.from("Bridge")], - bridgePublicKey, - ); - const [tokenBridgeConfig] = await PublicKey.findProgramAddress( - [Buffer.from("config")], - portalPublicKey, - ); - const [custody] = await PublicKey.findProgramAddress( - [swimUsdMintPublicKey.toBytes()], - portalPublicKey, - ); - const [custodySigner] = await PublicKey.findProgramAddress( - [Buffer.from("custody_signer")], - portalPublicKey, - ); - const [authoritySigner] = await PublicKey.findProgramAddress( - [Buffer.from("authority_signer")], - portalPublicKey, - ); - const [wormholeEmitter] = await PublicKey.findProgramAddress( - [Buffer.from("emitter")], - portalPublicKey, - ); - const [wormholeSequence] = await PublicKey.findProgramAddress( - [Buffer.from("Sequence"), wormholeEmitter.toBytes()], - bridgePublicKey, + private getRoutingContract(wallet: SolanaWalletAdapter): Program { + const walletPublicKey = wallet.publicKey; + if (walletPublicKey === null) { + throw new Error("Missing Solana wallet"); + } + const anchorProvider = new AnchorProvider( + this.connection, + { + ...wallet, + publicKey: walletPublicKey, + }, + { commitment: "confirmed" }, ); - const [wormholeFeeCollector] = await PublicKey.findProgramAddress( - [Buffer.from("fee_collector")], - bridgePublicKey, + return new Program( + idl.propeller, + this.chainConfig.routingContractAddress, + anchorProvider, ); - return { - propeller: new PublicKey(this.chainConfig.routingContractStateAddress), - tokenProgram: TOKEN_PROGRAM_ID, - payer: walletPublicKey, - wormhole: bridgePublicKey, - tokenBridgeConfig, - userSwimUsdAta: swimUsdAtaPublicKey, - swimUsdMint: swimUsdMintPublicKey, - custody, - tokenBridge: portalPublicKey, - custodySigner, - authoritySigner, - wormholeConfig, - wormholeMessage: auxiliarySigner, - wormholeEmitter, - wormholeSequence, - wormholeFeeCollector, - clock: SYSVAR_CLOCK_PUBKEY, - }; } private async propellerAdd({ @@ -921,7 +977,8 @@ export class SolanaClient extends Client< }, {} as ReadonlyRecord, ); - const addAccounts = this.getAddAccounts( + const addAccounts = getAddAccounts( + this.chainConfig, userTokenAccounts[TokenProjectId.SwimUsd], [ userTokenAccounts[TokenProjectId.Usdc], @@ -981,7 +1038,8 @@ export class SolanaClient extends Client< new PublicKey(this.chainConfig.swimUsdDetails.address), senderPublicKey, ); - const transferAccounts = await this.getPropellerTransferAccounts( + const transferAccounts = await getPropellerTransferAccounts( + this.chainConfig, senderPublicKey, swimUsdTokenAccount, auxiliarySigner.publicKey, diff --git a/packages/solana/src/getAccounts.ts b/packages/solana/src/getAccounts.ts new file mode 100644 index 000000000..de45dc124 --- /dev/null +++ b/packages/solana/src/getAccounts.ts @@ -0,0 +1,296 @@ +import { + getClaimAddressSolana, + getEmitterAddressEth, +} from "@certusone/wormhole-sdk"; +import type { Accounts } from "@project-serum/anchor"; +import { BN } from "@project-serum/anchor"; +import { TOKEN_PROGRAM_ID, getAssociatedTokenAddress } from "@solana/spl-token"; +import { + PublicKey, + SYSVAR_CLOCK_PUBKEY, + SYSVAR_RENT_PUBKEY, + SystemProgram, +} from "@solana/web3.js"; +import type { ChainConfig } from "@swim-io/core/types"; +import * as byteify from "byteify"; +import keccak256 from "keccak256"; + +import type { SolanaChainConfig } from "./protocol"; + +export const getAddAccounts = ( + solanaChainConfig: SolanaChainConfig, + userSwimUsdAtaPublicKey: PublicKey, + userTokenAccounts: readonly PublicKey[], + auxiliarySigner: PublicKey, + lpMint: PublicKey, + poolTokenAccounts: readonly PublicKey[], + poolGovernanceFeeAccount: PublicKey, +): Accounts => { + return { + propeller: new PublicKey(solanaChainConfig.routingContractStateAddress), + tokenProgram: TOKEN_PROGRAM_ID, + poolTokenAccount0: poolTokenAccounts[0], + poolTokenAccount1: poolTokenAccounts[1], + lpMint, + governanceFee: poolGovernanceFeeAccount, + userTransferAuthority: auxiliarySigner, + userTokenAccount0: userTokenAccounts[0], + userTokenAccount1: userTokenAccounts[1], + userLpTokenAccount: userSwimUsdAtaPublicKey, + twoPoolProgram: new PublicKey(solanaChainConfig.twoPoolContractAddress), + }; +}; + +export const getPropellerTransferAccounts = async ( + solanaChainConfig: SolanaChainConfig, + walletPublicKey: PublicKey, + swimUsdAtaPublicKey: PublicKey, + auxiliarySigner: PublicKey, +): Promise => { + const bridgePublicKey = new PublicKey(solanaChainConfig.wormhole.bridge); + const portalPublicKey = new PublicKey(solanaChainConfig.wormhole.portal); + const swimUsdMintPublicKey = new PublicKey( + solanaChainConfig.swimUsdDetails.address, + ); + const [wormholeConfig] = await PublicKey.findProgramAddress( + [Buffer.from("Bridge")], + bridgePublicKey, + ); + const [tokenBridgeConfig] = await PublicKey.findProgramAddress( + [Buffer.from("config")], + portalPublicKey, + ); + const [custody] = await PublicKey.findProgramAddress( + [swimUsdMintPublicKey.toBytes()], + portalPublicKey, + ); + const [custodySigner] = await PublicKey.findProgramAddress( + [Buffer.from("custody_signer")], + portalPublicKey, + ); + const [authoritySigner] = await PublicKey.findProgramAddress( + [Buffer.from("authority_signer")], + portalPublicKey, + ); + const [wormholeEmitter] = await PublicKey.findProgramAddress( + [Buffer.from("emitter")], + portalPublicKey, + ); + const [wormholeSequence] = await PublicKey.findProgramAddress( + [Buffer.from("Sequence"), wormholeEmitter.toBytes()], + bridgePublicKey, + ); + const [wormholeFeeCollector] = await PublicKey.findProgramAddress( + [Buffer.from("fee_collector")], + bridgePublicKey, + ); + return { + propeller: new PublicKey(solanaChainConfig.routingContractStateAddress), + tokenProgram: TOKEN_PROGRAM_ID, + payer: walletPublicKey, + wormhole: bridgePublicKey, + tokenBridgeConfig, + userSwimUsdAta: swimUsdAtaPublicKey, + swimUsdMint: swimUsdMintPublicKey, + custody, + tokenBridge: portalPublicKey, + custodySigner, + authoritySigner, + wormholeConfig, + wormholeMessage: auxiliarySigner, + wormholeEmitter, + wormholeSequence, + wormholeFeeCollector, + clock: SYSVAR_CLOCK_PUBKEY, + }; +}; + +const hashVaa = (signedVaa: Buffer): Buffer => { + const sigStart = 6; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const numSigners = signedVaa[5]!; + const sigLength = 66; + const body = signedVaa.subarray(sigStart + sigLength * numSigners); + return keccak256(Buffer.from(body)); +}; + +export const createCompleteNativeWithPayloadAccounts = async ( + solanaChainConfig: SolanaChainConfig, + walletPublicKey: PublicKey, + signedVaa: Buffer, + swimUsdAtaPublicKey: PublicKey, + sourceWormholeChainId: number, + sourceChainConfig: ChainConfig, +): Promise => { + const bridgePublicKey = new PublicKey(solanaChainConfig.wormhole.bridge); + const portalPublicKey = new PublicKey(solanaChainConfig.wormhole.portal); + const swimUsdMintPublicKey = new PublicKey( + solanaChainConfig.swimUsdDetails.address, + ); + const [tokenBridgeConfig] = await PublicKey.findProgramAddress( + [Buffer.from("config")], + portalPublicKey, + ); + const [custody] = await PublicKey.findProgramAddress( + [swimUsdMintPublicKey.toBytes()], + portalPublicKey, + ); + const [custodySigner] = await PublicKey.findProgramAddress( + [Buffer.from("custody_signer")], + portalPublicKey, + ); + + const hash = hashVaa(signedVaa); + const [message] = await PublicKey.findProgramAddress( + [Buffer.from("PostedVAA"), hash], + bridgePublicKey, + ); + const claim = await getClaimAddressSolana( + portalPublicKey.toBase58(), + signedVaa, + ); + + const [endpoint] = await PublicKey.findProgramAddress( + [ + byteify.serializeUint16(sourceWormholeChainId), + Buffer.from( + getEmitterAddressEth(sourceChainConfig.wormhole.portal), + "hex", + ), + ], + portalPublicKey, + ); + + const propellerRedeemer = ( + await PublicKey.findProgramAddress( + [Buffer.from("redeemer")], + new PublicKey(solanaChainConfig.routingContractAddress), + ) + )[0]; + const propellerRedeemerEscrowAccount = await getAssociatedTokenAddress( + swimUsdMintPublicKey, + propellerRedeemer, + true, + ); + + return { + propeller: new PublicKey(solanaChainConfig.routingContractStateAddress), + payer: walletPublicKey, + tokenBridgeConfig, + message, + claim, + endpoint, + to: propellerRedeemerEscrowAccount, + redeemer: propellerRedeemer, + feeRecipient: swimUsdAtaPublicKey, + custody, + swimUsdMint: swimUsdMintPublicKey, + custodySigner, + rent: SYSVAR_RENT_PUBKEY, + systemProgram: SystemProgram.programId, + wormhole: bridgePublicKey, + tokenProgram: TOKEN_PROGRAM_ID, + tokenBridge: portalPublicKey, + }; +}; + +const getSwimPayloadMessagePda = async ( + wormholeClaim: PublicKey, + propellerProgramId: PublicKey, +): Promise => { + return await PublicKey.findProgramAddress( + [ + Buffer.from("propeller"), + Buffer.from("swim_payload"), + wormholeClaim.toBuffer(), + ], + propellerProgramId, + ); +}; + +const getToTokenNumberMapAddr = async ( + propellerState: PublicKey, + toTokenNumber: number, + propellerProgramId: PublicKey, +) => { + return await PublicKey.findProgramAddress( + [ + Buffer.from("propeller"), + Buffer.from("token_id"), + propellerState.toBuffer(), + new BN(toTokenNumber).toArrayLike(Buffer, "le", 2), + ], + propellerProgramId, + ); +}; + +export const createProcessSwimPayloadAccounts = async ( + solanaChainConfig: SolanaChainConfig, + walletPublicKey: PublicKey, + signedVaa: Buffer, + swimUsdAtaPublicKey: PublicKey, + userTokenAccounts: readonly PublicKey[], + poolTokenAccounts: readonly PublicKey[], + governanceFeeKey: PublicKey, + toTokenNumber: number, +) => { + const propeller = new PublicKey( + solanaChainConfig.routingContractStateAddress, + ); + const portalPublicKey = new PublicKey(solanaChainConfig.wormhole.portal); + const swimUsdMintPublicKey = new PublicKey( + solanaChainConfig.swimUsdDetails.address, + ); + const claim = await getClaimAddressSolana( + portalPublicKey.toBase58(), + signedVaa, + ); + const propellerProgramId = new PublicKey( + solanaChainConfig.routingContractAddress, + ); + const [swimPayloadMessage] = await getSwimPayloadMessagePda( + claim, + propellerProgramId, + ); + const propellerRedeemer = ( + await PublicKey.findProgramAddress( + [Buffer.from("redeemer")], + propellerProgramId, + ) + )[0]; + const propellerRedeemerEscrowAccount = await getAssociatedTokenAddress( + swimUsdMintPublicKey, + propellerRedeemer, + true, + ); + const twoPoolConfig = solanaChainConfig.pools[0]; + const twoPoolProgramId = new PublicKey(twoPoolConfig.contract); + const twoPoolAddress = new PublicKey(twoPoolConfig.address); + const [tokenIdMap] = await getToTokenNumberMapAddr( + propeller, + toTokenNumber, + propellerProgramId, + ); + return { + propeller, + payer: walletPublicKey, + claim, + swimPayloadMessage: new PublicKey(swimPayloadMessage), + swimPayloadMessagePayer: walletPublicKey, + redeemer: propellerRedeemer, + redeemerEscrow: propellerRedeemerEscrowAccount, + pool: twoPoolAddress, + poolTokenAccount0: poolTokenAccounts[0], + poolTokenAccount1: poolTokenAccounts[1], + lpMint: swimUsdMintPublicKey, + governanceFee: governanceFeeKey, + userTransferAuthority: walletPublicKey, + userTokenAccount0: userTokenAccounts[0], + userTokenAccount1: userTokenAccounts[1], + userLpTokenAccount: swimUsdAtaPublicKey, + tokenProgram: TOKEN_PROGRAM_ID, + twoPoolProgram: twoPoolProgramId, + systemProgram: SystemProgram.programId, + tokenIdMap, + }; +}; diff --git a/packages/solana/src/protocol.ts b/packages/solana/src/protocol.ts index fd4b43525..84e4fa96e 100644 --- a/packages/solana/src/protocol.ts +++ b/packages/solana/src/protocol.ts @@ -44,6 +44,8 @@ export enum SolanaTxType { WormholePostVaa = "wormhole:postVaa", SwimPropellerAdd = "swimPropeller:add", SwimPropellerTransfer = "swimPropeller:transfer", + SwimCompleteNativeWithPayload = "swim:completeNativeWithPayload", + SwimProcessSwimPayload = "swim:processSwimPayload", } export type SolanaTx = Tx; diff --git a/yarn.lock b/yarn.lock index 9170f8b16..b04a6d91f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7826,6 +7826,7 @@ __metadata: "@typescript-eslint/eslint-plugin": ^5.38.1 "@typescript-eslint/parser": ^5.38.1 bn.js: ^5.2.1 + byteify: ^2.0.10 decimal.js: ^10.3.1 eslint: ^8.18.0 eslint-config-prettier: ^8.5.0 @@ -7837,6 +7838,7 @@ __metadata: eslint-plugin-prettier: ^4.0.0 ethers: ^5.7.0 jest: ^28.1.1 + keccak256: ^1.0.6 prettier: ^2.7.1 ts-jest: ^28.0.5 typescript: ~4.8.4 @@ -7977,7 +7979,7 @@ __metadata: "@swim-io/evm": ^0.40.0 "@swim-io/evm-contracts": ^0.40.0 "@swim-io/pool-math": ^0.40.0 - "@swim-io/solana": ^0.40.0 + "@swim-io/solana": "workspace:^" "@swim-io/solana-contracts": ^0.40.0 "@swim-io/token-projects": ^0.40.0 "@swim-io/tsconfig": "workspace:^"