diff --git a/package-lock.json b/package-lock.json index e3d737ca667..d8d98ffde89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5797,56 +5797,6 @@ "version": "4.0.0", "license": "ISC" }, - "node_modules/@coinflowlabs/react": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@coinflowlabs/react/-/react-5.5.1.tgz", - "integrity": "sha512-3KKjEUqlzTJGO9KNKfkelWnoY3ksGmRPc8rb9zoaa5ofN4BKKciNiVGRK0I3vo76+Lgh6fyZ5/W+WEtZEECutQ==", - "license": "Apache-2.0", - "dependencies": { - "@nsure-ai/web-client-sdk": "^1.1.90", - "bn.js": "^5.2.2", - "bs58": "~5.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@coinflowlabs/lib-common": "*", - "@solana/web3.js": ">=1.54.0", - "bs58": "~5.0.0", - "react": ">=16" - }, - "peerDependenciesMeta": { - "@coinflowlabs/lib-common": { - "optional": true - }, - "@solana/web3.js": { - "optional": true - }, - "bs58": { - "optional": true - } - } - }, - "node_modules/@coinflowlabs/react/node_modules/base-x": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", - "license": "MIT" - }, - "node_modules/@coinflowlabs/react/node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", - "license": "MIT" - }, - "node_modules/@coinflowlabs/react/node_modules/bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "license": "MIT", - "dependencies": { - "base-x": "^4.0.0" - } - }, "node_modules/@commander-js/extra-typings": { "version": "12.1.0", "license": "MIT", @@ -97919,7 +97869,7 @@ "@audius/fixed-decimal": "*", "@audius/sdk": "*", "@fingerprintjs/fingerprintjs-pro": "3.5.6", - "@jup-ag/api": "6.0.44", + "@jup-ag/api": "6.0.48", "@metaplex-foundation/mpl-token-metadata": "2.5.2", "@optimizely/optimizely-sdk": "4.0.0", "@tanstack/react-query": "5.62.7", @@ -97972,6 +97922,12 @@ "redux-saga": "1.1.3" } }, + "packages/common/node_modules/@jup-ag/api": { + "version": "6.0.48", + "resolved": "https://registry.npmjs.org/@jup-ag/api/-/api-6.0.48.tgz", + "integrity": "sha512-H66m/cIqdVIA0qLI2X76UOhuMXkS/+uI6e4KQuU3fn6FSBhCX/9fwt/4IdwES4KWXwGtvqhsg2ExkB9tRtNhyA==", + "license": "MIT" + }, "packages/common/node_modules/@metaplex-foundation/mpl-token-metadata": { "version": "2.5.2", "license": "MIT", @@ -133837,7 +133793,7 @@ "@audius/harmony": "*", "@audius/sdk": "*", "@cloudflare/kv-asset-handler": "0.2.0", - "@coinflowlabs/react": "5.5.1", + "@coinflowlabs/react": "5.9.1", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", @@ -134100,6 +134056,35 @@ "url": "https://paulmillr.com/funding/" } }, + "packages/web/node_modules/@coinflowlabs/react": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@coinflowlabs/react/-/react-5.9.1.tgz", + "integrity": "sha512-9oFeB1nnantT/hzG71lj1rMduHohU1/vTbebG6tA+6n+8l/MUrX6jb+93EK8sOFQnVZQqQ6BpD1Uaf/R13OZWQ==", + "license": "Apache-2.0", + "dependencies": { + "@nsure-ai/web-client-sdk": "^1.1.90", + "bn.js": "^5.2.3", + "bs58": "~5.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@coinflowlabs/lib-common": "*", + "@solana/web3.js": ">=1.54.0", + "bs58": "~5.0.0", + "react": ">=16" + }, + "peerDependenciesMeta": { + "@coinflowlabs/lib-common": { + "optional": true + }, + "@solana/web3.js": { + "optional": true + }, + "bs58": { + "optional": true + } + } + }, "packages/web/node_modules/@electron/notarize": { "version": "2.2.0", "dev": true, @@ -135962,6 +135947,12 @@ "version": "5.0.0", "license": "MIT" }, + "packages/web/node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, "packages/web/node_modules/body-scroll-lock": { "version": "4.0.0-beta.0", "license": "MIT" diff --git a/packages/common/package.json b/packages/common/package.json index 25347972946..a8c642b6c1e 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -39,7 +39,7 @@ "@audius/fixed-decimal": "*", "@audius/sdk": "*", "@fingerprintjs/fingerprintjs-pro": "3.5.6", - "@jup-ag/api": "6.0.44", + "@jup-ag/api": "6.0.48", "@metaplex-foundation/mpl-token-metadata": "2.5.2", "@optimizely/optimizely-sdk": "4.0.0", "@tanstack/react-query": "5.62.7", diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index c5886b0d30a..a3360332785 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -155,6 +155,7 @@ export * from './tan-query/wallets/useAudioBalance' export * from './tan-query/wallets/useAssociatedWallets' export * from './tan-query/wallets/useWalletOwner' export * from './tan-query/wallets/useUSDCBalance' +export * from './tan-query/wallets/useDestinationUsdcAccountCheck' export * from './tan-query/wallets/useExternalWalletBalance' export * from './tan-query/wallets/useCoinBalance' export * from './tan-query/wallets/useCoinBalanceBreakdown' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index c761c3bf469..5d4afd81576 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -90,6 +90,7 @@ export const QUERY_KEYS = { walletOwner: 'walletOwner', tokenPrice: 'tokenPrice', usdcBalance: 'usdcBalance', + destinationUsdcAccount: 'destinationUsdcAccount', fileSizes: 'fileSizes', sendTokens: 'sendTokens', managedAccounts: 'managedAccounts', diff --git a/packages/common/src/api/tan-query/wallets/useDestinationUsdcAccountCheck.ts b/packages/common/src/api/tan-query/wallets/useDestinationUsdcAccountCheck.ts new file mode 100644 index 00000000000..dcf6443287c --- /dev/null +++ b/packages/common/src/api/tan-query/wallets/useDestinationUsdcAccountCheck.ts @@ -0,0 +1,88 @@ +import { getAssociatedTokenAddressSync } from '@solana/spl-token' +import { PublicKey } from '@solana/web3.js' +import { useQuery } from '@tanstack/react-query' + +import { SolanaWalletAddress } from '~/models/Wallet' +import { getJupiterQuoteByMintWithRetry } from '~/services/Jupiter' +import { SOL_MINT, TOKEN_LISTING_MAP } from '~/store/ui/shared/tokenConstants' +import { isValidSolAddress } from '~/store/wallet/utils' + +import { QUERY_KEYS } from '../queryKeys' +import type { QueryKey, QueryOptions } from '../types' +import { useQueryContext } from '../utils' + +/** Result of checking whether a destination wallet has a USDC token account */ +export type DestinationUsdcAccountStatus = + | { hasUsdcAccount: true } + | { hasUsdcAccount: false; ataCreationFeeUsdc: number } + +const USDC_DECIMALS = TOKEN_LISTING_MAP.USDC.decimals +const SOL_DECIMALS = TOKEN_LISTING_MAP.SOL.decimals +const TOKEN_ACCOUNT_SIZE = 165 +const ATA_TX_FEE_BUFFER_LAMPORTS = 10_000 + +export const getDestinationUsdcAccountQueryKey = ( + destinationAddress: string | null | undefined +) => + [ + QUERY_KEYS.destinationUsdcAccount, + destinationAddress + ] as unknown as QueryKey + +/** + * Checks whether a destination Solana address has a USDC token account. + * When it doesn't, returns the estimated one-time fee to create it. + * Use when the user pastes a destination address during USDC withdrawal (wallet route). + */ +export const useDestinationUsdcAccountCheck = ( + destinationAddress: string | null | undefined, + options?: QueryOptions +) => { + const { audiusSdk, env } = useQueryContext() + const isValidAddress = + !!destinationAddress && + isValidSolAddress(destinationAddress as SolanaWalletAddress) + + return useQuery({ + queryKey: getDestinationUsdcAccountQueryKey(destinationAddress), + queryFn: async () => { + const sdk = await audiusSdk() + const connection = sdk.services.solanaClient.connection + const mint = new PublicKey(env.USDC_MINT_ADDRESS) + const destinationWallet = new PublicKey(destinationAddress!) + + const destinationAta = getAssociatedTokenAddressSync( + mint, + destinationWallet, + true + ) + + const info = await connection.getAccountInfo(destinationAta) + if (info) return { hasUsdcAccount: true } + + const rentExemptLamports = + await connection.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_SIZE) + const totalSolNeededLamports = + rentExemptLamports + ATA_TX_FEE_BUFFER_LAMPORTS + const totalSolNeededUi = totalSolNeededLamports / 1e9 + + const { quoteResult: costQuote } = await getJupiterQuoteByMintWithRetry({ + inputMint: mint.toBase58(), + outputMint: SOL_MINT, + inputDecimals: USDC_DECIMALS, + outputDecimals: SOL_DECIMALS, + amountUi: totalSolNeededUi, + swapMode: 'ExactOut', + onlyDirectRoutes: false + }) + + const ataCreationFeeUsdc = + Number(BigInt(costQuote.inputAmount.amountString)) / 10 ** USDC_DECIMALS + + return { hasUsdcAccount: false, ataCreationFeeUsdc } + }, + enabled: isValidAddress && options?.enabled !== false, + staleTime: 60_000, + ...options + }) +} diff --git a/packages/common/src/api/tan-query/wallets/useSendCoins.ts b/packages/common/src/api/tan-query/wallets/useSendCoins.ts index 5ee1b7eecf9..0e999a6e23d 100644 --- a/packages/common/src/api/tan-query/wallets/useSendCoins.ts +++ b/packages/common/src/api/tan-query/wallets/useSendCoins.ts @@ -164,7 +164,11 @@ export const useSendCoins = ({ mint }: { mint: string }) => { } } }, - onError: (error, { amount, recipientWallet, source, recipientHandle }, context) => { + onError: ( + error, + { amount, recipientWallet, source, recipientHandle }, + context + ) => { if (context?.previousBalance) { const userId = currentUser?.user_id ?? null const queryKey = getUserCoinQueryKey(mint, userId) diff --git a/packages/common/src/messages/walletMessages.ts b/packages/common/src/messages/walletMessages.ts index 315646df33a..e7c2d6d26d6 100644 --- a/packages/common/src/messages/walletMessages.ts +++ b/packages/common/src/messages/walletMessages.ts @@ -77,6 +77,12 @@ export const walletMessages = { amountTooLow: 'Amount must be greater than zero.', invalidAddress: 'A valid Solana USDC wallet address is required', minCashTransfer: 'A minimum of $5 is required for cash withdrawals.', + ataCreationFeeRequired: (feeDollars: string) => + `Amount must cover the one-time account creation fee of $${feeDollars}`, + noUsdcAccountFound: (feeDollars?: string) => + feeDollars != null + ? `No USDC account found. A one-time fee of $${feeDollars} will be deducted.` + : 'No USDC account found', pleaseConfirm: 'Please confirm you have reviewed this transaction and accept responsibility for errors.', youMustConfirm: diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index 6c674e6ea7d..d2f57492342 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -5,6 +5,7 @@ import { TOKEN_PROGRAM_ID, TokenInstruction, createAssociatedTokenAccountIdempotentInstruction, + createCloseAccountInstruction, createTransferCheckedInstruction, decodeTransferCheckedInstruction, getAccount, @@ -23,9 +24,28 @@ import { import { CommonStoreContext } from '~/store/storeContext' import { AnalyticsEvent, Name } from '../../models' +import { + convertJupiterInstructions, + getJupiterQuoteByMintWithRetry, + jupiterInstance +} from '../Jupiter' import { AudiusBackend } from './AudiusBackend' +const SOL_MINT = 'So11111111111111111111111111111111111111112' +const USDC_DECIMALS = 6 +/** Jupiter swap lookup table - needed for swap instruction account resolution */ +const JUPITER_SWAP_LOOKUP_TABLE = new PublicKey( + '2WB87JxGZieRd7hi3y87wq6HAsPLyb9zrSx8B5z1QEzM' +) +const SOL_DECIMALS = 9 +// Token account size in bytes - used to compute rent exemption +const TOKEN_ACCOUNT_SIZE = 165 +// Extra lamports for tx fees when pre-funding ATA creation +const ATA_TX_FEE_BUFFER_LAMPORTS = 10_000 +// Buffer for quote variance between ExactOut cost quote and ExactIn swap output +const ATA_PREFUND_QUOTE_BUFFER_LAMPORTS = 3000 + const DEFAULT_RETRY_DELAY = 1000 const DEFAULT_MAX_RETRY_COUNT = 120 export const RECOVERY_MEMO_STRING = 'Recover Withdrawal' @@ -421,14 +441,171 @@ export const recoverUsdcFromRootWallet = async ({ return signature } +/** + * Creates a destination ATA funded by the user's own USDC via relay: + * TX1 (via relay): Create root USDC ATA, transfer fee USDC from user bank, + * swap USDC->wSOL to fee payer's wSOL ATA, close wSOL (unwrap to fee payer), + * create recipient USDC ATA (fee payer), close root USDC ATA (to fee payer). + * TX2: transferFromUserBank sends the withdrawal amount to the recipient ATA. + */ +const createUserFundedAta = async ({ + sdk, + connection, + keypair, + mint, + ethWallet, + destination, + destinationWallet +}: { + sdk: AudiusSdkWithServices + connection: Connection + keypair: Keypair + mint: PublicKey + ethWallet: string + destination: PublicKey + destinationWallet: PublicKey +}): Promise => { + const feePayer = await sdk.services.solanaRelay.getFeePayer() + const solMint = new PublicKey(SOL_MINT) + + // Calculate the amount of USDC needed to create the destination ATA + const rentExemptLamports = + await connection.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_SIZE) + const totalSolNeededLamports = rentExemptLamports + ATA_TX_FEE_BUFFER_LAMPORTS + const costQuoteTargetLamports = + totalSolNeededLamports + ATA_PREFUND_QUOTE_BUFFER_LAMPORTS + const { quoteResult: costQuote } = await getJupiterQuoteByMintWithRetry({ + inputMint: mint.toBase58(), + outputMint: SOL_MINT, + inputDecimals: USDC_DECIMALS, + outputDecimals: SOL_DECIMALS, + amountUi: costQuoteTargetLamports / 1e9, + swapMode: 'ExactOut', + onlyDirectRoutes: false + }) + const feeAmountUsdc = BigInt(costQuote.inputAmount.amountString) + const feeAmountUsdcUi = Number(feeAmountUsdc) / 10 ** USDC_DECIMALS + + const rootWalletUsdcAta = getAssociatedTokenAddressSync( + mint, + keypair.publicKey, + true + ) + const feePayerWsolAta = getAssociatedTokenAddressSync(solMint, feePayer, true) + + // Swap all USDC fee to SOL + const { quoteResult: swapQuote } = await getJupiterQuoteByMintWithRetry({ + inputMint: mint.toBase58(), + outputMint: SOL_MINT, + inputDecimals: USDC_DECIMALS, + outputDecimals: SOL_DECIMALS, + amountUi: feeAmountUsdcUi, + swapMode: 'ExactIn', + onlyDirectRoutes: false + }) + const receivedLamports = Number(swapQuote.outputAmount.amountString) + if (receivedLamports < totalSolNeededLamports) { + throw new Error( + `ATA prefund swap output insufficient: got ${receivedLamports} lamports, needed ${totalSolNeededLamports}` + ) + } + console.debug( + `createUserFundedAta: swapping ${feeAmountUsdc} USDC for ~${receivedLamports} lamports (need ${totalSolNeededLamports})` + ) + const swapInstructionsResult = await jupiterInstance.swapInstructionsPost({ + swapRequest: { + quoteResponse: swapQuote.quote, + userPublicKey: keypair.publicKey.toBase58(), + payer: keypair.publicKey.toBase58(), + destinationTokenAccount: feePayerWsolAta.toBase58(), + wrapAndUnwrapSol: false, + dynamicSlippage: true + } + }) + + // Combine all instructions into a single transaction + const prefundInstructions = [ + // Create root USDC ATA (fee payer) + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + rootWalletUsdcAta, + keypair.publicKey, + mint + ), + // Secp instruction (claimable transfer) + await sdk.services.claimableTokensClient.createTransferSecpInstruction({ + amount: feeAmountUsdc, + ethWallet, + mint, + destination: rootWalletUsdcAta, + instructionIndex: 1 + }), + // Claimable transfer (fee USDC from user bank → root ATA) + await sdk.services.claimableTokensClient.createTransferInstruction({ + ethWallet, + mint, + destination: rootWalletUsdcAta + }), + // Memo (INTERNAL_TRANSFER) + new TransactionInstruction({ + keys: [{ pubkey: rootWalletUsdcAta, isSigner: false, isWritable: true }], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(INTERNAL_TRANSFER_MEMO_STRING) + }), + // Create wSOL ATA on fee payer + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + feePayerWsolAta, + feePayer, + solMint + ), + // Jupiter swap (USDC → wSOL) + ...convertJupiterInstructions([swapInstructionsResult.swapInstruction]), + // Close wSOL ATA (unwrap to fee payer) + createCloseAccountInstruction(feePayerWsolAta, feePayer, feePayer), + // Create recipient USDC ATA (fee payer) + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + destination, + destinationWallet, + mint + ), + // Close root USDC ATA (to fee payer) + createCloseAccountInstruction(rootWalletUsdcAta, feePayer, feePayer) + ] + + const prefundTx = await sdk.services.solanaClient.buildTransaction({ + feePayer, + instructions: prefundInstructions, + addressLookupTables: [ + ...swapInstructionsResult.addressLookupTableAddresses.map( + (addr: string) => new PublicKey(addr) + ), + JUPITER_SWAP_LOOKUP_TABLE + ], + priorityFee: null, + computeLimit: null + }) + prefundTx.sign([keypair]) + const prefundSig = await sdk.services.solanaClient.sendTransaction( + prefundTx, + { skipPreflight: true } + ) + await connection.confirmTransaction(prefundSig, 'confirmed') + console.debug( + `createUserFundedAta: prefund + ATA creation confirmed: ${prefundSig}` + ) +} + /** * Transfers tokens out of a user bank. * Notes: * - Including a signer will mark this transfer as a "withdrawal preparation" * by signing a memo indicating such. This prevents the transfer from showing * as a withdrawal on the withdrawal history page. - * - Users have restrictions on creating token accounts via relay, so if the - * destination token account doesn't exist this might fail. + * - If keypair is provided and the destination token account doesn't exist, the + * user pays a USDC fee (swapped to SOL) to fund creation of the destination ATA, + * bypassing the relay rate limit. Otherwise falls back to relay-funded creation. */ type TransferFromUserBankParams = { sdk: AudiusSdkWithServices @@ -447,6 +624,12 @@ type TransferFromUserBankParams = { analyticsFields: any /** If included, will attach a signed memo indicating a recovery transaction. */ signer?: Keypair + /** + * The user's root Solana keypair. When provided and the destination ATA is + * missing, the user pays a small USDC fee (swapped to SOL via Jupiter) to + * fund ATA creation themselves, avoiding the relay's daily rate limit. + */ + keypair?: Keypair } export const transferFromUserBank = async ({ @@ -459,7 +642,8 @@ export const transferFromUserBank = async ({ track, make, analyticsFields, - signer + signer, + keypair }: TransferFromUserBankParams) => { let isCreatingTokenAccount = false try { @@ -511,24 +695,39 @@ export const transferFromUserBank = async ({ `Ensuring associated token account ${destination.toBase58()} exists...` ) - // Historically, the token account was created in a separate transaction - // after swapping USDC to SOL via Jupiter and funded via the root wallet. - // This is no longer the case. Reusing the same Amplitude events anyway. await track( make({ eventName: Name.WITHDRAW_USDC_CREATE_DEST_TOKEN_ACCOUNT_START, ...analyticsFields }) ) - const payerKey = await sdk.services.solanaRelay.getFeePayer() - const createAtaInstruction = - createAssociatedTokenAccountIdempotentInstruction( - payerKey, + + if (keypair) { + // User-funded ATA creation: swap a USDC fee to SOL, then create the + // destination ATA directly from the root wallet — the relay never sees + // an unmatched create instruction, so its rate limit is not involved. + await createUserFundedAta({ + sdk, + connection, + keypair, + mint, + ethWallet, destination, - destinationWallet, - mint + destinationWallet + }) + // ATA now exists on-chain; no instruction needed in the main tx. + } else { + // Fallback: relay-funded ATA creation (subject to daily rate limit) + const payerKey = await sdk.services.solanaRelay.getFeePayer() + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payerKey, + destination, + destinationWallet, + mint + ) ) - instructions.push(createAtaInstruction) + } } } diff --git a/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts b/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts index 6196dd95757..264f5b40108 100644 --- a/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts +++ b/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts @@ -12,6 +12,8 @@ export enum WithdrawUSDCModalPages { export type WithdrawUSDCModalState = { page: WithdrawUSDCModalPages + /** One-time fee in USDC dollars when destination has no USDC account (wallet route) */ + ataCreationFeeUsdc?: number } const withdrawUSDCModal = createModal({ diff --git a/packages/common/src/store/ui/withdraw-usdc/sagas.ts b/packages/common/src/store/ui/withdraw-usdc/sagas.ts index 89141736f20..86a20a21a6b 100644 --- a/packages/common/src/store/ui/withdraw-usdc/sagas.ts +++ b/packages/common/src/store/ui/withdraw-usdc/sagas.ts @@ -86,7 +86,8 @@ function* doWithdrawUSDCCoinflow({ track, make, analyticsFields, - signer: rootSolanaAccount + signer: rootSolanaAccount, + keypair: rootSolanaAccount }) console.debug( @@ -209,9 +210,11 @@ function* doWithdrawUSDCManualTransfer({ const withdrawalAmountDollars = amount / 100 const queryClient = yield* getContext('queryClient') const sdk = yield* getSDK() + const solanaWalletService = yield* getContext('solanaWalletService') const connection = sdk.services.solanaClient.connection const env = yield* getContext('env') const mint = new PublicKey(env.USDC_MINT_ADDRESS) + const rootSolanaAccount = yield* call([solanaWalletService, 'getKeypair']) const analyticsFields: WithdrawUSDCTransferEventFields = { destinationAddress, @@ -244,7 +247,8 @@ function* doWithdrawUSDCManualTransfer({ destinationWallet, track, make, - analyticsFields + analyticsFields, + keypair: rootSolanaAccount ?? undefined }) console.debug('Withdraw USDC - successfully transferred USDC.', { diff --git a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts index 143cc8d9521..b52b56774af 100644 --- a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts +++ b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts @@ -9,6 +9,7 @@ import { NATIVE_MINT, TOKEN_PROGRAM_ID, createApproveInstruction, + createAssociatedTokenAccountIdempotentInstruction, createAssociatedTokenAccountInstruction, createCloseAccountInstruction, createInitializeAccountInstruction, @@ -302,6 +303,85 @@ describe('Solana Relay', function () { 'Mint not allowed' ) }) + + it('should allow exactly one fee-payer-funded create without matching close when fee payer receives rent exemption via System Transfer', async function () { + const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151' + const feePayer = config.solanaFeePayerWallets[0].publicKey + const fromPubkey = getRandomPublicKey() + const recipientAta = getRandomPublicKey() + const recipientOwner = getRandomPublicKey() + const rentExemptionLamports = 2_039_280 + const instructions = [ + SystemProgram.transfer({ + fromPubkey, + toPubkey: feePayer, + lamports: rentExemptionLamports + }), + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + recipientAta, + recipientOwner, + usdcMintKey + ) + ] + await assertRelayAllowedInstructions(instructions, { + user: { wallet, is_verified: false }, + feePayer: feePayer.toBase58() + }) + }) + + it('should not allow fee-payer-funded create when fee payer receives less than rent exemption via System Transfer', async function () { + const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151' + const feePayer = config.solanaFeePayerWallets[0].publicKey + const fromPubkey = getRandomPublicKey() + const recipientAta = getRandomPublicKey() + const recipientOwner = getRandomPublicKey() + const belowRentExemptionLamports = 2_039_279 + const instructions = [ + SystemProgram.transfer({ + fromPubkey, + toPubkey: feePayer, + lamports: belowRentExemptionLamports + }), + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + recipientAta, + recipientOwner, + usdcMintKey + ) + ] + await assert.rejects( + async () => + assertRelayAllowedInstructions(instructions, { + user: { wallet, is_verified: false }, + feePayer: feePayer.toBase58() + }), + InvalidRelayInstructionError, + 'Mismatched number of create and close instructions' + ) + }) + + it('should not allow fee-payer-funded create without matching close when there is no reimbursement', async function () { + const feePayer = config.solanaFeePayerWallets[0].publicKey + const recipientAta = getRandomPublicKey() + const recipientOwner = getRandomPublicKey() + const instructions = [ + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + recipientAta, + recipientOwner, + usdcMintKey + ) + ] + await assert.rejects( + async () => + assertRelayAllowedInstructions(instructions, { + feePayer: feePayer.toBase58() + }), + InvalidRelayInstructionError, + 'Mismatched number of create and close instructions' + ) + }) }) describe('Token Program', function () { diff --git a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts index 6bb23075e05..f35076af1c1 100644 --- a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts +++ b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts @@ -79,6 +79,103 @@ const findSpecificMemo = ( return null } +/** Rent exemption for a 165-byte token account (lamports) */ +const RENT_EXEMPTION_TOKEN_ACCOUNT = 2_039_280 + +/** + * Returns true if the tx contains an instruction that sends >= rent exemption + * lamports to the fee payer (System Transfer or Token CloseAccount). + */ +const feePayerReceivesRentExemptionOrMore = ( + instructions: TransactionInstruction[], + feePayer: string +): boolean => { + for (const instr of instructions) { + if (instr.programId.equals(SystemProgram.programId)) { + try { + const type = SystemInstruction.decodeInstructionType(instr) + if (type === 'Transfer') { + const decoded = SystemInstruction.decodeTransfer(instr) + if ( + decoded.toPubkey.toBase58() === feePayer && + decoded.lamports >= RENT_EXEMPTION_TOKEN_ACCOUNT + ) { + return true + } + } + } catch { + /* skip invalid */ + } + } else if (instr.programId.equals(TOKEN_PROGRAM_ID)) { + try { + const decoded = decodeInstruction(instr) + if ( + isCloseAccountInstruction(decoded) && + decoded.keys.destination.pubkey.toBase58() === feePayer + ) { + return true + } + } catch { + /* skip invalid */ + } + } + } + return false +} + +/** + * Counts fee-payer-funded ATA creates that have no matching close. + */ +const countUnmatchedFeePayerCreates = ( + instructions: TransactionInstruction[], + feePayer: string +): number => { + const feePayerCreatesWithoutClose: Set = new Set() + const allAtaCreates: Array<{ associatedToken: PublicKey; payer: PublicKey }> = + [] + + for (const instr of instructions) { + if (!instr.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) continue + try { + const decoded = decodeAssociatedTokenAccountInstruction(instr) + if ( + isCreateAssociatedTokenAccountInstruction(decoded) || + isCreateAssociatedTokenAccountIdempotentInstruction(decoded) + ) { + const payer = decoded.keys.payer.pubkey.toBase58() + if (payer === feePayer) { + allAtaCreates.push({ + associatedToken: decoded.keys.associatedToken.pubkey, + payer: decoded.keys.payer.pubkey + }) + } + } + } catch { + /* skip */ + } + } + + for (const create of allAtaCreates) { + const hasMatchingClose = instructions.some((instr) => { + if (!instr.programId.equals(TOKEN_PROGRAM_ID)) return false + try { + const decoded = decodeInstruction(instr) + return ( + isCloseAccountInstruction(decoded) && + create.associatedToken.equals(decoded.keys.account.pubkey) && + create.payer.equals(decoded.keys.destination.pubkey) + ) + } catch { + return false + } + }) + if (!hasMatchingClose) { + feePayerCreatesWithoutClose.add(create.associatedToken.toBase58()) + } + } + return feePayerCreatesWithoutClose.size +} + /** * Only allow the createTokenAccount instruction of the Associated Token * Account program, provided it has matching close instructions. @@ -88,7 +185,8 @@ const assertAllowedAssociatedTokenAccountProgramInstruction = async ( instructionIndex: number, instruction: TransactionInstruction, instructions: TransactionInstruction[], - user?: Pick | null + user?: Pick | null, + feePayer?: string | null ) => { const { wallet, is_verified: isVerified } = user ?? {} const decodedInstruction = @@ -117,6 +215,12 @@ const assertAllowedAssociatedTokenAccountProgramInstruction = async ( return } + // If the user pays for the create (not feePayer), there's no drain risk — allow without rate limit. + const createPayer = decodedInstruction.keys.payer.pubkey.toBase58() + if (feePayer && createPayer !== feePayer) { + return + } + // Protect against feePayer drain by ensuring that there's always as // many account close instructions as creates const matchingCreateInstructions = instructions @@ -145,38 +249,49 @@ const assertAllowedAssociatedTokenAccountProgramInstruction = async ( ) ) if ( - wallet && matchingCreateInstructions.length !== matchingCloseInstructions.length ) { - try { - let memo: string | undefined - if (findSpecificMemo(instructions, PAYOUT_WALLET_MEMO)) { - memo = PAYOUT_WALLET_MEMO - } else if (findSpecificMemo(instructions, PREPARE_WITHDRAWAL_MEMO)) { - memo = PREPARE_WITHDRAWAL_MEMO - } - const isAbusive = await isUserAbusive(wallet) - if (isAbusive) { + // Reimbursement exception: allow exactly one fee-payer-funded create without + // matching close when fee payer receives >= rent exemption in the same tx. + if ( + feePayer && + feePayerReceivesRentExemptionOrMore(instructions, feePayer) && + countUnmatchedFeePayerCreates(instructions, feePayer) === 1 + ) { + return + } + if (wallet) { + try { + let memo: string | undefined + if (findSpecificMemo(instructions, PAYOUT_WALLET_MEMO)) { + memo = PAYOUT_WALLET_MEMO + } else if (findSpecificMemo(instructions, PREPARE_WITHDRAWAL_MEMO)) { + memo = PREPARE_WITHDRAWAL_MEMO + } + const isAbusive = await isUserAbusive(wallet) + if (isAbusive) { + throw new InvalidRelayInstructionError( + instructionIndex, + 'User is abusive' + ) + } + // In this situation, we could be losing SOL because the user is allowed to + // close their own ATA and reclaim the rent that we've fronted, so + // rate limit it cautiously. + await rateLimitTokenAccountCreation(wallet, !!isVerified, memo) + } catch (e) { + const error = e as Error throw new InvalidRelayInstructionError( instructionIndex, - 'User is abusive' + error.message ) } - // In this situation, we could be losing SOL because the user is allowed to - // close their own ATA and reclaim the rent that we've fronted, so - // rate limit it cautiously. - await rateLimitTokenAccountCreation(wallet, !!isVerified, memo) - } catch (e) { - const error = e as Error - throw new InvalidRelayInstructionError(instructionIndex, error.message) + } else { + throw new InvalidRelayInstructionError( + instructionIndex, + `Mismatched number of create and close instructions for account: ${decodedInstruction.keys.associatedToken.pubkey.toBase58()}` + ) } - } else if ( - matchingCreateInstructions.length !== matchingCloseInstructions.length - ) { - throw new InvalidRelayInstructionError( - instructionIndex, - `Mismatched number of create and close instructions for account: ${decodedInstruction.keys.associatedToken.pubkey.toBase58()}` - ) } } else { throw new InvalidRelayInstructionError( @@ -527,7 +642,8 @@ export const assertRelayAllowedInstructions = async ( i, instruction, instructions, - options?.user + options?.user, + options?.feePayer ) break case TOKEN_PROGRAM_ID.toBase58(): diff --git a/packages/mobile/src/components/withdraw-usdc-drawer/components/EnterTransferDetails.tsx b/packages/mobile/src/components/withdraw-usdc-drawer/components/EnterTransferDetails.tsx index 35ee8ce5ee0..a915e43d166 100644 --- a/packages/mobile/src/components/withdraw-usdc-drawer/components/EnterTransferDetails.tsx +++ b/packages/mobile/src/components/withdraw-usdc-drawer/components/EnterTransferDetails.tsx @@ -1,5 +1,6 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' +import { useDestinationUsdcAccountCheck } from '@audius/common/api' import { walletMessages } from '@audius/common/messages' import { WithdrawUSDCModalPages, @@ -10,7 +11,10 @@ import { ADDRESS, type WithdrawUSDCFormValues as WithdrawFormValues } from '@audius/common/store' -import { decimalIntegerToHumanReadable } from '@audius/common/utils' +import { + decimalIntegerToHumanReadable, + filterDecimalString +} from '@audius/common/utils' import { BottomSheetTextInput } from '@gorhom/bottom-sheet' import { useField, useFormikContext } from 'formik' @@ -24,18 +28,48 @@ export const EnterTransferDetails = ({ }: { balanceNumberCents: number }) => { - const { validateForm } = useFormikContext() + const { validateForm, setFieldError } = useFormikContext() const [ { value: amountValue }, { error: amountError, touched: amountTouched }, { setValue: setAmount, setTouched: setAmountTouched } ] = useField(AMOUNT) - const [, { error: addressError }, { setTouched: setAddressTouched }] = + const [{ value: addressValue }, { error: addressError }, { setTouched: setAddressTouched }] = useField(ADDRESS) + + const { data: destinationUsdcStatus } = useDestinationUsdcAccountCheck( + methodValue === WithdrawMethod.MANUAL_TRANSFER ? addressValue : null + ) + const { setData } = useWithdrawUSDCModal() const [{ value: methodValue }, _ignoredMethodMeta, { setValue: setMethod }] = useField(METHOD) + const isInsufficientForAtaFee = useMemo(() => { + if ( + methodValue !== WithdrawMethod.MANUAL_TRANSFER || + !destinationUsdcStatus || + destinationUsdcStatus.hasUsdcAccount + ) { + return false + } + const amountCents = + typeof amountValue === 'string' + ? filterDecimalString(amountValue).value + : amountValue + const feeCents = Math.ceil( + destinationUsdcStatus.ataCreationFeeUsdc * 100 + ) + return ( + amountCents < feeCents || amountCents + feeCents > balanceNumberCents + ) + }, [ + methodValue, + destinationUsdcStatus, + amountValue, + balanceNumberCents + ]) + const onContinuePress = useCallback(async () => { setAmountTouched(true) if (methodValue === WithdrawMethod.MANUAL_TRANSFER) { @@ -43,13 +77,63 @@ export const EnterTransferDetails = ({ } const errors = await validateForm() if (errors[AMOUNT] || errors[ADDRESS]) return - setData({ page: WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS }) - }, [validateForm, setData, setAmountTouched, setAddressTouched, methodValue]) + + if (methodValue === WithdrawMethod.MANUAL_TRANSFER && destinationUsdcStatus) { + if (!destinationUsdcStatus.hasUsdcAccount) { + const feeCents = Math.ceil( + destinationUsdcStatus.ataCreationFeeUsdc * 100 + ) + const amountCents = + typeof amountValue === 'string' + ? filterDecimalString(amountValue).value + : amountValue + if ( + amountCents < feeCents || + amountCents + feeCents > balanceNumberCents + ) { + setFieldError( + AMOUNT, + walletMessages.errors.ataCreationFeeRequired( + destinationUsdcStatus.ataCreationFeeUsdc.toFixed(2) + ) + ) + return + } + } + } + + setData({ + page: WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS, + ...(destinationUsdcStatus && !destinationUsdcStatus.hasUsdcAccount + ? { + ataCreationFeeUsdc: destinationUsdcStatus.ataCreationFeeUsdc + } + : {}) + }) + }, [ + validateForm, + setData, + setAmountTouched, + setAddressTouched, + setFieldError, + methodValue, + destinationUsdcStatus, + amountValue, + balanceNumberCents + ]) const handleMaxPress = useCallback(() => { - const maxHumanized = decimalIntegerToHumanReadable(balanceNumberCents) + const maxCents = + destinationUsdcStatus && !destinationUsdcStatus.hasUsdcAccount + ? Math.max( + 0, + balanceNumberCents - + Math.ceil(destinationUsdcStatus.ataCreationFeeUsdc * 100) + ) + : balanceNumberCents + const maxHumanized = decimalIntegerToHumanReadable(maxCents) setAmount(maxHumanized) - }, [balanceNumberCents, setAmount]) + }, [balanceNumberCents, destinationUsdcStatus, setAmount]) const handleAmountFocus = useCallback(() => { // Clear the field if it contains the default value @@ -98,11 +182,16 @@ export const EnterTransferDetails = ({ {walletMessages.max} - {amountTouched && amountError && ( + {amountTouched && + (amountError || + (isInsufficientForAtaFee && destinationUsdcStatus)) ? ( - {amountError} + {amountError ?? + walletMessages.errors.ataCreationFeeRequired( + destinationUsdcStatus!.ataCreationFeeUsdc.toFixed(2) + )} - )} + ) : null} @@ -145,14 +234,19 @@ export const EnterTransferDetails = ({ errorBeforeSubmit required /> + {destinationUsdcStatus && !destinationUsdcStatus.hasUsdcAccount ? ( + + {walletMessages.errors.noUsdcAccountFound( + !amountError && !isInsufficientForAtaFee + ? destinationUsdcStatus.ataCreationFeeUsdc.toFixed(2) + : undefined + )} + + ) : null} )} - diff --git a/packages/web/package.json b/packages/web/package.json index 82775c916df..5e1e8d81b3c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -74,7 +74,7 @@ "@audius/harmony": "*", "@audius/sdk": "*", "@cloudflare/kv-asset-handler": "0.2.0", - "@coinflowlabs/react": "5.5.1", + "@coinflowlabs/react": "5.9.1", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", diff --git a/packages/web/src/components/withdraw-usdc-modal/components/EnterTransferDetails.tsx b/packages/web/src/components/withdraw-usdc-modal/components/EnterTransferDetails.tsx index 10c4d7f7e05..484faf4a168 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/EnterTransferDetails.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/components/EnterTransferDetails.tsx @@ -2,10 +2,14 @@ import { ChangeEventHandler, FocusEventHandler, useCallback, + useMemo, useState } from 'react' -import { useUSDCBalance } from '@audius/common/api' +import { + useUSDCBalance, + useDestinationUsdcAccountCheck +} from '@audius/common/api' import { useFeatureFlag } from '@audius/common/hooks' import { walletMessages } from '@audius/common/messages' import { Name } from '@audius/common/models' @@ -42,7 +46,7 @@ const WithdrawMethodOptions = [ ] export const EnterTransferDetails = () => { - const { validateForm } = useFormikContext() + const { validateForm, setFieldError } = useFormikContext() const { data: balance } = useUSDCBalance() const { setData } = useWithdrawUSDCModal() @@ -61,13 +65,37 @@ export const EnterTransferDetails = () => { const [ { value }, - { error: amountError }, + { error: amountError, touched: amountTouched }, { setValue: setAmount, setTouched: setAmountTouched } ] = useField(AMOUNT) const [{ value: methodValue }, _ignoredMethodMeta, { setValue: setMethod }] = useField(METHOD) - const [, { error: addressError }, { setTouched: setAddressTouched }] = - useField(ADDRESS) + const [ + { value: addressValue }, + { error: addressError, touched: addressTouched }, + { setTouched: setAddressTouched } + ] = useField(ADDRESS) + + const { data: destinationUsdcStatus } = useDestinationUsdcAccountCheck( + methodValue === WithdrawMethod.MANUAL_TRANSFER ? addressValue : null + ) + + const isInsufficientForAtaFee = useMemo(() => { + if ( + methodValue !== WithdrawMethod.MANUAL_TRANSFER || + !destinationUsdcStatus || + destinationUsdcStatus.hasUsdcAccount + ) { + return false + } + const amountCents = + typeof value === 'string' ? filterDecimalString(value).value : value + const feeCents = Math.ceil(destinationUsdcStatus.ataCreationFeeUsdc * 100) + return ( + amountCents < feeCents || amountCents + feeCents > balanceNumberCents + ) + }, [methodValue, destinationUsdcStatus, value, balanceNumberCents]) + const [humanizedValue, setHumanizedValue] = useState( value ? decimalIntegerToHumanReadable(value) : '0' ) @@ -82,14 +110,23 @@ export const EnterTransferDetails = () => { const handleAmountBlur: FocusEventHandler = useCallback( (e) => { setHumanizedValue(padDecimalValue(e.target.value)) + setAmountTouched(true) }, - [setHumanizedValue] + [setHumanizedValue, setAmountTouched] ) const handleMaxPress = useCallback(() => { - setHumanizedValue(decimalIntegerToHumanReadable(balanceNumberCents)) - setAmount(balanceNumberCents) - }, [balanceNumberCents, setAmount, setHumanizedValue]) + const maxCents = + destinationUsdcStatus && !destinationUsdcStatus.hasUsdcAccount + ? Math.max( + 0, + balanceNumberCents - + Math.ceil(destinationUsdcStatus.ataCreationFeeUsdc * 100) + ) + : balanceNumberCents + setHumanizedValue(decimalIntegerToHumanReadable(maxCents)) + setAmount(maxCents) + }, [balanceNumberCents, destinationUsdcStatus, setAmount, setHumanizedValue]) const handlePasteAddress = useCallback( (event: React.ClipboardEvent) => { @@ -112,8 +149,51 @@ export const EnterTransferDetails = () => { } const errors = await validateForm() if (errors[AMOUNT] || errors[ADDRESS]) return - setData({ page: WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS }) - }, [setData, methodValue, validateForm, setAmountTouched, setAddressTouched]) + + if ( + methodValue === WithdrawMethod.MANUAL_TRANSFER && + destinationUsdcStatus + ) { + if (!destinationUsdcStatus.hasUsdcAccount) { + const feeCents = Math.ceil( + destinationUsdcStatus.ataCreationFeeUsdc * 100 + ) + const amountCents = + typeof value === 'string' ? filterDecimalString(value).value : value + if ( + amountCents < feeCents || + amountCents + feeCents > balanceNumberCents + ) { + setFieldError( + AMOUNT, + walletMessages.errors.ataCreationFeeRequired( + destinationUsdcStatus.ataCreationFeeUsdc.toFixed(2) + ) + ) + return + } + } + } + + setData({ + page: WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS, + ...(destinationUsdcStatus && !destinationUsdcStatus.hasUsdcAccount + ? { + ataCreationFeeUsdc: destinationUsdcStatus.ataCreationFeeUsdc + } + : {}) + }) + }, [ + setData, + methodValue, + validateForm, + setAmountTouched, + setAddressTouched, + setFieldError, + destinationUsdcStatus, + value, + balanceNumberCents + ]) return ( @@ -124,7 +204,6 @@ export const EnterTransferDetails = () => { {walletMessages.amountToWithdraw} - {walletMessages.destinationDescription} @@ -137,16 +216,24 @@ export const EnterTransferDetails = () => { onChange={handleAmountChange} onBlur={handleAmountBlur} startAdornmentText={messages.dollars} + error={amountTouched && !!(amountError || (isInsufficientForAtaFee && destinationUsdcStatus))} + helperText={ + amountTouched && (amountError || (isInsufficientForAtaFee && destinationUsdcStatus)) + ? (amountError ?? + (destinationUsdcStatus + ? walletMessages.errors.ataCreationFeeRequired( + destinationUsdcStatus.ataCreationFeeUsdc.toFixed( + 2 + ) + ) + : undefined)) + : undefined + } /> - {amountError && ( - - {amountError} - - )} @@ -179,12 +266,20 @@ export const EnterTransferDetails = () => { name={ADDRESS} placeholder='' /> + {destinationUsdcStatus && !destinationUsdcStatus.hasUsdcAccount ? ( + + {walletMessages.errors.noUsdcAccountFound( + !amountError && !isInsufficientForAtaFee + ? destinationUsdcStatus.ataCreationFeeUsdc.toFixed(2) + : undefined + )} + + ) : null} )}