diff --git a/packages/common/src/hooks/useCoinflowAdapter.ts b/packages/common/src/hooks/useCoinflowAdapter.ts index 48c7bf32bf3..cb7352e1a33 100644 --- a/packages/common/src/hooks/useCoinflowAdapter.ts +++ b/packages/common/src/hooks/useCoinflowAdapter.ts @@ -2,9 +2,15 @@ import { useEffect, useState } from 'react' import { TransactionHandler } from '@audius/sdk/dist/core' import { Connection, PublicKey, Transaction } from '@solana/web3.js' +import { useSelector } from 'react-redux' -import { getRootSolanaAccount } from 'services/audius-backend' +import { + getRootSolanaAccount, + decorateCoinflowWithdrawalTransaction, + relayTransaction +} from 'services/audius-backend' import { useAppContext } from 'src/context' +import { getFeePayer } from 'store/solana/selectors' type CoinflowAdapter = { wallet: { @@ -14,6 +20,68 @@ type CoinflowAdapter = { connection: Connection } +/** An adapter for signing and sending Coinflow withdrawal transactions. It will decorate + * the incoming transaction to route it through a user bank. The transcation will then be + * signed with the current user's Solana root wallet and sent/confirmed via Relay. + */ +export const useCoinflowWithdrawalAdapter = () => { + const { audiusBackend } = useAppContext() + const [adapter, setAdapter] = useState(null) + const feePayerOverride = useSelector(getFeePayer) + + useEffect(() => { + const initWallet = async () => { + const libs = await audiusBackend.getAudiusLibsTyped() + if (!libs.solanaWeb3Manager) return + const { connection } = libs.solanaWeb3Manager + const wallet = await getRootSolanaAccount(audiusBackend) + + setAdapter({ + connection, + wallet: { + publicKey: wallet.publicKey, + sendTransaction: async (transaction: Transaction) => { + if (!feePayerOverride) throw new Error('Missing fee payer override') + const feePayer = new PublicKey(feePayerOverride) + const finalTransaction = + await decorateCoinflowWithdrawalTransaction(audiusBackend, { + transaction, + feePayer + }) + finalTransaction.partialSign(wallet) + const { res, error, errorCode } = await relayTransaction( + audiusBackend, + { + transaction: finalTransaction, + skipPreflight: true + } + ) + if (!res) { + console.error('Relaying Coinflow transaction failed.', { + error, + errorCode, + finalTransaction + }) + throw new Error( + `Relaying Coinflow transaction failed: ${ + error ?? 'Unknown error' + }` + ) + } + return res + } + } + }) + } + initWallet() + }, [audiusBackend]) + + return adapter +} + +/** An adapter for signing and sending unmodified Coinflow transactions. Will partialSign with the + * current user's Solana root wallet and send/confirm locally (no relay). + */ export const useCoinflowAdapter = () => { const { audiusBackend } = useAppContext() const [adapter, setAdapter] = useState(null) diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index aefe5659a6c..659d08e1b22 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -1,5 +1,12 @@ import { AudiusLibs } from '@audius/sdk' -import { Account, createTransferCheckedInstruction } from '@solana/spl-token' +import { u8 } from '@solana/buffer-layout' +import { + Account, + TOKEN_PROGRAM_ID, + TokenInstruction, + createTransferCheckedInstruction, + decodeTransferCheckedInstruction +} from '@solana/spl-token' import { AddressLookupTableAccount, Keypair, @@ -20,7 +27,9 @@ import { AudiusBackend } from './AudiusBackend' const DEFAULT_RETRY_DELAY = 1000 const DEFAULT_MAX_RETRY_COUNT = 120 const PLACEHOLDER_SIGNATURE = new Array(64).fill(0) -const RECOVERY_MEMO_STRING = 'recovery' +export const RECOVERY_MEMO_STRING = 'Recover Withdrawal' +export const WITHDRAWAL_MEMO_STRING = 'Withdrawal' +export const PREPARE_WITHDRAWAL_MEMO_STRING = 'Prepare Withdrawal' /** * Memo program V1 @@ -121,6 +130,16 @@ export const deriveUserBankAddress = async ( return pubkey.toString() as SolanaWalletAddress } +export const isTransferCheckedInstruction = ( + instruction: TransactionInstruction +) => { + return ( + instruction.programId.equals(TOKEN_PROGRAM_ID) && + instruction.data.length && + u8().decode(instruction.data) === TokenInstruction.TransferChecked + ) +} + type CreateUserBankIfNeededConfig = UserBankConfig & { recordAnalytics: (event: AnalyticsEvent, callback?: () => void) => void feePayerOverride: string @@ -465,6 +484,102 @@ export const createRootWalletRecoveryTransaction = async ( return tx } +/** Converts a Coinflow transaction which transfers directly from root wallet USDC + * account into a transaction that routes through the current user's USDC user bank, to + * better facilitate indexing. The original transaction *must* use a TransferChecked instruction + * and must have the current user's Solana root wallet USDC token account as the source. + * @returns a new transaction that routes the USDC transfer through the user bank. This must be signed + * by the current user's Solana root wallet and the provided fee payer (likely via relay). + */ +export const decorateCoinflowWithdrawalTransaction = async ( + audiusBackendInstance: AudiusBackend, + { transaction, feePayer }: { transaction: Transaction; feePayer: PublicKey } +) => { + const libs = await audiusBackendInstance.getAudiusLibsTyped() + const solanaWeb3Manager = libs.solanaWeb3Manager! + + const userBank = await deriveUserBankPubkey(audiusBackendInstance, { + mint: 'usdc' + }) + const wallet = await getRootSolanaAccount(audiusBackendInstance) + const walletUSDCTokenAccount = + await solanaWeb3Manager.findAssociatedTokenAddress( + wallet.publicKey.toBase58(), + 'usdc' + ) + + // Find original transfer instruction and index + const transferInstructionIndex = transaction.instructions.findIndex( + isTransferCheckedInstruction + ) + const transferInstruction = transaction.instructions[transferInstructionIndex] + if (!transferInstruction) { + throw new Error('No transfer instruction found') + } + + const { keys, data } = decodeTransferCheckedInstruction( + transferInstruction, + TOKEN_PROGRAM_ID + ) + if (!walletUSDCTokenAccount.equals(keys.source.pubkey)) { + throw new Error( + `Original sender ${keys.source.pubkey} does not match wallet ${walletUSDCTokenAccount}` + ) + } + + const transferToUserBankInstruction = createTransferCheckedInstruction( + walletUSDCTokenAccount, + keys.mint.pubkey, + userBank, + wallet.publicKey, + data.amount, + data.decimals + ) + + const transferFromUserBankInstructions = + await solanaWeb3Manager.createTransferInstructionsFromCurrentUser({ + amount: new BN(data.amount.toString()), + mint: 'usdc', + senderSolanaAddress: userBank, + recipientSolanaAddress: keys.destination.pubkey.toBase58(), + instructionIndex: transferInstructionIndex + 1, + feePayerKey: feePayer + }) + + const withdrawalMemoInstruction = new TransactionInstruction({ + keys: [ + { + pubkey: wallet.publicKey, + isSigner: true, + isWritable: true + } + ], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(WITHDRAWAL_MEMO_STRING) + }) + + // Remove original transfer instruction and replace with our set of transfer steps + const instructions = [...transaction.instructions] + instructions.splice( + transferInstructionIndex, + 1, + transferToUserBankInstruction, + ...transferFromUserBankInstructions, + withdrawalMemoInstruction + ) + + const { blockhash, lastValidBlockHeight } = + await solanaWeb3Manager.connection.getLatestBlockhash() + const modifiedTransaction = new Transaction({ + blockhash, + feePayer, + lastValidBlockHeight + }) + modifiedTransaction.add(...instructions) + + return modifiedTransaction +} + export const createTransferToUserBankTransaction = async ( audiusBackendInstance: AudiusBackend, { diff --git a/packages/common/src/store/buy-usdc/sagas.ts b/packages/common/src/store/buy-usdc/sagas.ts index 8218bae91ce..768eacc6ed1 100644 --- a/packages/common/src/store/buy-usdc/sagas.ts +++ b/packages/common/src/store/buy-usdc/sagas.ts @@ -9,6 +9,7 @@ import { Name } from 'models/Analytics' import { ErrorLevel } from 'models/ErrorReporting' import { PurchaseVendor } from 'models/PurchaseContent' import { Status } from 'models/Status' +import { StringUSDC } from 'models/Wallet' import { createPaymentRouterRouteTransaction, createRootWalletRecoveryTransaction, @@ -31,6 +32,7 @@ import { import { coinflowOnrampModalActions } from 'store/ui/modals/coinflow-onramp-modal' import { setVisibility } from 'store/ui/modals/parentSlice' import { initializeStripeModal } from 'store/ui/stripe-modal/slice' +import { setUSDCBalance } from 'store/wallet/slice' import { waitForValue } from 'utils' import { @@ -46,8 +48,6 @@ import { } from './slice' import { BuyUSDCError, BuyUSDCErrorCode } from './types' import { getBuyUSDCRemoteConfig, getUSDCUserBank } from './utils' -import { setUSDCBalance } from 'store/wallet/slice' -import { StringUSDC } from 'models/Wallet' type PurchaseStepParams = { desiredAmount: number diff --git a/packages/common/src/store/reducers.ts b/packages/common/src/store/reducers.ts index 27133eed8df..8d8d79711a7 100644 --- a/packages/common/src/store/reducers.ts +++ b/packages/common/src/store/reducers.ts @@ -61,10 +61,10 @@ import smartCollection from './pages/smart-collection/slice' import tokenDashboardSlice from './pages/token-dashboard/slice' import track from './pages/track/reducer' import TrackPageState from './pages/track/types' -import trendingPlaylists from './pages/trending-playlists/slice' -import trendingUnderground from './pages/trending-underground/slice' import trending from './pages/trending/reducer' import { TrendingPageState } from './pages/trending/types' +import trendingPlaylists from './pages/trending-playlists/slice' +import trendingUnderground from './pages/trending-underground/slice' import { PlaybackPositionState } from './playback-position' import playbackPosition from './playback-position/slice' import player, { PlayerState } from './player/slice' diff --git a/packages/common/src/store/ui/modals/reducers.ts b/packages/common/src/store/ui/modals/reducers.ts index 6c1734df22c..30581930156 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -2,6 +2,7 @@ import { Action, combineReducers, Reducer } from '@reduxjs/toolkit' import { addFundsModalReducer } from './add-funds-modal' import { coinflowOnrampModalReducer } from './coinflow-onramp-modal' +import { coinflowWithdrawModalReducer } from './coinflow-withdraw-modal' import { createChatModalReducer } from './create-chat-modal' import { BaseModalState } from './createModal' import { editPlaylistModalReducer } from './edit-playlist-modal' @@ -15,7 +16,6 @@ import { usdcManualTransferModalReducer } from './usdc-manual-transfer-modal' import { usdcPurchaseDetailsModalReducer } from './usdc-purchase-details-modal' import { usdcTransactionDetailsModalReducer } from './usdc-transaction-details-modal' import { withdrawUSDCModalReducer } from './withdraw-usdc-modal' -import { coinflowWithdrawModalReducer } from './coinflow-withdraw-modal' /** * Create a bunch of reducers that do nothing, so that the state is maintained and not lost through the child reducers diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index efc643224b9..96c55e4a09d 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -2,6 +2,7 @@ import { ModalSource } from 'models/Analytics' import { AddFundsModalState } from './add-funds-modal' import { CoinflowOnrampModalState } from './coinflow-onramp-modal' +import { CoinflowWithdrawModalState } from './coinflow-withdraw-modal' import { CreateChatModalState } from './create-chat-modal' import { BaseModalState } from './createModal' import { EditPlaylistModalState } from './edit-playlist-modal' @@ -13,7 +14,6 @@ import { USDCManualTransferModalState } from './usdc-manual-transfer-modal' import { USDCPurchaseDetailsModalState } from './usdc-purchase-details-modal' import { USDCTransactionDetailsModalState } from './usdc-transaction-details-modal' import { WithdrawUSDCModalState } from './withdraw-usdc-modal' -import { CoinflowWithdrawModalState } from './coinflow-withdraw-modal' export type Modals = | 'TiersExplainer' diff --git a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.module.css b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.module.css index 406b1c20bd8..51c6ab72de0 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.module.css +++ b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.module.css @@ -3,7 +3,7 @@ } .modalBody { - height: 100%; + height: 95%; max-width: 720px; max-height: 800px; } diff --git a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx index 5549bc69ae3..b2546280d4f 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/components/CoinflowWithdrawModal.tsx @@ -1,9 +1,9 @@ import { useCallback } from 'react' import { - useCoinflowAdapter, useCoinflowWithdrawModal, - withdrawUSDCActions + withdrawUSDCActions, + useCoinflowWithdrawalAdapter } from '@audius/common' import { CoinflowWithdraw } from '@coinflowlabs/react' import { useDispatch } from 'react-redux' @@ -40,7 +40,7 @@ export const CoinflowWithdrawModal = () => { onClosed } = useCoinflowWithdrawModal() - const adapter = useCoinflowAdapter() + const adapter = useCoinflowWithdrawalAdapter() const dispatch = useDispatch() const handleClose = useCallback(() => { @@ -54,7 +54,7 @@ export const CoinflowWithdrawModal = () => { dispatch(coinflowWithdrawalSucceeded({ transaction })) onClose() }, - [dispatch] + [dispatch, onClose] ) const showContent = isOpen && adapter diff --git a/packages/web/src/store/application/ui/withdraw-usdc/sagas.ts b/packages/web/src/store/application/ui/withdraw-usdc/sagas.ts index 026f213e8f1..6e6a4112193 100644 --- a/packages/web/src/store/application/ui/withdraw-usdc/sagas.ts +++ b/packages/web/src/store/application/ui/withdraw-usdc/sagas.ts @@ -1,10 +1,11 @@ import { withdrawUSDCActions, + MEMO_PROGRAM_ID, + PREPARE_WITHDRAWAL_MEMO_STRING, solanaSelectors, ErrorLevel, SolanaWalletAddress, getUSDCUserBank, - getContext, TOKEN_LISTING_MAP, getUserbankAccountInfo, BNUSDC, @@ -16,6 +17,7 @@ import { withdrawUSDCModalActions, WithdrawUSDCModalPages, WithdrawMethod, + getContext, buyUSDCActions, Status } from '@audius/common' @@ -27,6 +29,7 @@ import { LAMPORTS_PER_SOL, PublicKey, Transaction, + TransactionInstruction, sendAndConfirmTransaction } from '@solana/web3.js' import BN from 'bn.js' @@ -312,6 +315,18 @@ function* doWithdrawUSDCCoinflow({ } ) + const memoInstruction = new TransactionInstruction({ + keys: [ + { + pubkey: rootSolanaAccount.publicKey, + isSigner: true, + isWritable: true + } + ], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(PREPARE_WITHDRAWAL_MEMO_STRING) + }) + // Relay the withdrawal transfer so that the user doesn't need SOL if the account already exists const { blockhash, lastValidBlockHeight } = yield* call([ connection, @@ -322,7 +337,10 @@ function* doWithdrawUSDCCoinflow({ lastValidBlockHeight, feePayer: feePayerPubkey }) - transferTransaction.add(...transferInstructions) + + transferTransaction.add(...transferInstructions, memoInstruction) + transferTransaction.partialSign(rootSolanaAccount) + const { res: transactionSignature, error,