Skip to content

Commit

Permalink
[PAY-2407][PAY-2441] Route withdraw transactions through user bank (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
schottra authored and raymondjacobson committed Feb 5, 2024
1 parent 03fd837 commit 2507065
Show file tree
Hide file tree
Showing 9 changed files with 217 additions and 16 deletions.
70 changes: 69 additions & 1 deletion packages/common/src/hooks/useCoinflowAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<CoinflowAdapter | null>(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<CoinflowAdapter | null>(null)
Expand Down
119 changes: 117 additions & 2 deletions packages/common/src/services/audius-backend/solana.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
{
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/store/buy-usdc/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/store/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/store/ui/modals/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/store/ui/modals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
}

.modalBody {
height: 100%;
height: 95%;
max-width: 720px;
max-height: 800px;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -40,7 +40,7 @@ export const CoinflowWithdrawModal = () => {
onClosed
} = useCoinflowWithdrawModal()

const adapter = useCoinflowAdapter()
const adapter = useCoinflowWithdrawalAdapter()
const dispatch = useDispatch()

const handleClose = useCallback(() => {
Expand All @@ -54,7 +54,7 @@ export const CoinflowWithdrawModal = () => {
dispatch(coinflowWithdrawalSucceeded({ transaction }))
onClose()
},
[dispatch]
[dispatch, onClose]
)

const showContent = isOpen && adapter
Expand Down
22 changes: 20 additions & 2 deletions packages/web/src/store/application/ui/withdraw-usdc/sagas.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
withdrawUSDCActions,
MEMO_PROGRAM_ID,
PREPARE_WITHDRAWAL_MEMO_STRING,
solanaSelectors,
ErrorLevel,
SolanaWalletAddress,
getUSDCUserBank,
getContext,
TOKEN_LISTING_MAP,
getUserbankAccountInfo,
BNUSDC,
Expand All @@ -16,6 +17,7 @@ import {
withdrawUSDCModalActions,
WithdrawUSDCModalPages,
WithdrawMethod,
getContext,
buyUSDCActions,
Status
} from '@audius/common'
Expand All @@ -27,6 +29,7 @@ import {
LAMPORTS_PER_SOL,
PublicKey,
Transaction,
TransactionInstruction,
sendAndConfirmTransaction
} from '@solana/web3.js'
import BN from 'bn.js'
Expand Down Expand Up @@ -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,
Expand All @@ -322,7 +337,10 @@ function* doWithdrawUSDCCoinflow({
lastValidBlockHeight,
feePayer: feePayerPubkey
})
transferTransaction.add(...transferInstructions)

transferTransaction.add(...transferInstructions, memoInstruction)
transferTransaction.partialSign(rootSolanaAccount)

const {
res: transactionSignature,
error,
Expand Down

0 comments on commit 2507065

Please sign in to comment.