From 4f6afb1f7656938fad4844e47f8480852074e25d Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 16 Mar 2026 17:19:52 -0700 Subject: [PATCH 1/8] Fix USDC withdrawals and token transfers by upgrading Coinflow SDK and adding user-funded ATA creation - Upgrade @coinflowlabs/react from 5.5.1 to ^5.9.1 - Add prefundAtaCreationFromUsdc: when the destination wallet lacks a USDC token account, swap a small USDC fee to SOL via Jupiter (ExactOut) and deliver the SOL to the user's root wallet so it can pay for ATA rent, bypassing the relay's 10/day rate limit - Pass keypair to transferFromUserBank in both Coinflow and manual transfer sagas so user-funded ATA creation is used automatically - Relax relay ATA rate limiting when the ATA rent payer is the user's own root wallet (not the relay fee payer), since the user is self-funding - Add relay test for the user-funded ATA creation path Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 87 +++---- .../src/services/audius-backend/solana.ts | 214 ++++++++++++++++-- .../src/store/ui/withdraw-usdc/sagas.ts | 8 +- .../assertRelayAllowedInstructions.test.ts | 24 ++ .../relay/assertRelayAllowedInstructions.ts | 51 +++-- packages/web/package.json | 2 +- 6 files changed, 296 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3d737ca667..66a1e1c5e34 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", @@ -133837,7 +133787,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 +134050,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 +135941,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/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index 6c674e6ea7d..c2b2b4c7da1 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,22 @@ 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 +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 + const DEFAULT_RETRY_DELAY = 1000 const DEFAULT_MAX_RETRY_COUNT = 120 export const RECOVERY_MEMO_STRING = 'Recover Withdrawal' @@ -421,14 +435,150 @@ export const recoverUsdcFromRootWallet = async ({ return signature } +/** + * Swaps a small amount of USDC from the user's userbank to native SOL and + * delivers the SOL to `keypair.publicKey`. Used to pre-fund the user's root + * wallet so it can pay for destination ATA rent without going through the + * relay's rate-limited token-account-creation path. + */ +const prefundAtaCreationFromUsdc = async ({ + sdk, + connection, + keypair, + mint, + ethWallet +}: { + sdk: AudiusSdkWithServices + connection: Connection + keypair: Keypair + mint: PublicKey + ethWallet: string +}): Promise => { + const feePayer = await sdk.services.solanaRelay.getFeePayer() + + // How much SOL we need: ATA rent + small buffer for the surrounding tx fee + const rentExemptLamports = + await connection.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_SIZE) + const totalSolNeededLamports = rentExemptLamports + ATA_TX_FEE_BUFFER_LAMPORTS + const totalSolNeededUi = totalSolNeededLamports / 1e9 + + // Get a Jupiter quote: USDC → SOL, ExactOut so we receive exactly the rent amount + const { quoteResult: solQuote } = await getJupiterQuoteByMintWithRetry({ + inputMint: mint.toBase58(), + outputMint: SOL_MINT, + inputDecimals: USDC_DECIMALS, + outputDecimals: SOL_DECIMALS, + amountUi: totalSolNeededUi, + swapMode: 'ExactOut', + onlyDirectRoutes: false + }) + + const feeAmountUsdc = BigInt(solQuote.inputAmount.amountString) + console.debug( + `prefundAtaCreationFromUsdc: swapping ${feeAmountUsdc} USDC for ${totalSolNeededLamports} lamports` + ) + + const prefundInstructions: TransactionInstruction[] = [] + + // Create root wallet's temporary USDC ATA (idempotent; relay pays rent, reclaimed at end) + const rootWalletUsdcAta = getAssociatedTokenAddressSync( + mint, + keypair.publicKey, + true + ) + prefundInstructions.push( + createAssociatedTokenAccountIdempotentInstruction( + feePayer, + rootWalletUsdcAta, + keypair.publicKey, + mint + ) + ) + + // Transfer USDC fee from userbank to root wallet's USDC ATA + const secpFeeInstruction = + await sdk.services.claimableTokensClient.createTransferSecpInstruction({ + amount: feeAmountUsdc, + ethWallet, + mint, + destination: rootWalletUsdcAta, + instructionIndex: prefundInstructions.length + }) + prefundInstructions.push(secpFeeInstruction) + + const transferFeeInstruction = + await sdk.services.claimableTokensClient.createTransferInstruction({ + ethWallet, + mint, + destination: rootWalletUsdcAta + }) + prefundInstructions.push(transferFeeInstruction) + + // Internal transfer memo (required for USDC transfers) + prefundInstructions.push( + new TransactionInstruction({ + keys: [{ pubkey: rootWalletUsdcAta, isSigner: false, isWritable: true }], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(INTERNAL_TRANSFER_MEMO_STRING) + }) + ) + + // Jupiter swap: root wallet's USDC ATA → native SOL (unwrapped into keypair's wallet) + const swapRequest = { + quoteResponse: solQuote.quote, + userPublicKey: keypair.publicKey.toBase58(), + wrapAndUnwrapSol: true, + dynamicSlippage: true + } + let swapInstructionsResult + try { + swapInstructionsResult = await jupiterInstance.swapInstructionsPost({ + swapRequest: { ...swapRequest, useSharedAccounts: true } + }) + } catch { + swapInstructionsResult = await jupiterInstance.swapInstructionsPost({ + swapRequest: { ...swapRequest, useSharedAccounts: false } + }) + } + const [swapInstruction] = convertJupiterInstructions([ + swapInstructionsResult.swapInstruction + ]) + prefundInstructions.push(swapInstruction) + + // Close the temporary USDC ATA (returns relay's rent to fee payer) + prefundInstructions.push( + createCloseAccountInstruction(rootWalletUsdcAta, feePayer, keypair.publicKey) + ) + + // Build, sign, and send via relay (relay pays gas; keypair signs as swap authority) + const prefundTx = await sdk.services.solanaClient.buildTransaction({ + feePayer, + instructions: prefundInstructions, + addressLookupTables: swapInstructionsResult.addressLookupTableAddresses.map( + (addr: string) => new PublicKey(addr) + ) + }) + prefundTx.sign([keypair]) + const prefundSig = await sdk.services.solanaClient.sendTransaction(prefundTx, { + skipPreflight: true + }) + + // Wait for the SOL to land before the next transaction tries to spend it + await connection.confirmTransaction(prefundSig, 'confirmed') + console.debug( + `prefundAtaCreationFromUsdc: pre-fund tx 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 +597,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 +615,8 @@ export const transferFromUserBank = async ({ track, make, analyticsFields, - signer + signer, + keypair }: TransferFromUserBankParams) => { let isCreatingTokenAccount = false try { @@ -511,24 +668,44 @@ 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, - destination, - destinationWallet, - mint + + if (keypair) { + // User-funded ATA creation: swap USDC to SOL via Jupiter so the user's + // root wallet can pay for the destination ATA rent, bypassing relay limits. + await prefundAtaCreationFromUsdc({ + sdk, + connection, + keypair, + mint, + ethWallet + }) + // Root wallet is now the ATA rent payer (relay only pays gas for the tx) + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + keypair.publicKey, + destination, + destinationWallet, + mint + ) + ) + } 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) + } } } @@ -569,8 +746,13 @@ export const transferFromUserBank = async ({ instructions }) - if (signer) { - transaction.sign([signer]) + // Sign with keypair if creating user-funded ATA (as ATA rent payer) or as withdrawal signer + const signers = [signer, keypair && isCreatingTokenAccount ? keypair : null] + .filter((k): k is Keypair => k != null) + // Deduplicate in case signer === keypair + .filter((k, i, arr) => arr.findIndex(x => x.publicKey.equals(k.publicKey)) === i) + if (signers.length > 0) { + transaction.sign(signers) } const signature = 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..a2821313231 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 @@ -224,6 +224,30 @@ describe('Solana Relay', function () { await assertRelayAllowedInstructions(complexInstructions) }) + it('should allow user-funded ATA creation without close (payer is not relay fee payer)', async function () { + const feePayer = getRandomPublicKey() + const userWallet = getRandomPublicKey() + const associatedToken = getRandomPublicKey() + const owner = getRandomPublicKey() + const wallet = 'some-eth-wallet' + + // ATA payer is the user's root wallet (not the relay fee payer) – no rate limit, no close needed + const instructions = [ + createAssociatedTokenAccountInstruction( + userWallet, // user's root wallet pays for ATA rent + associatedToken, + owner, + usdcMintKey + ) + ] + await assert.doesNotReject(async () => + assertRelayAllowedInstructions(instructions, { + user: { wallet, is_verified: false }, + feePayer: feePayer.toBase58() // different from userWallet + }) + ) + }) + it('should not allow create token account without close', async function () { const payer = getRandomPublicKey() const associatedToken = getRandomPublicKey() 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..26ab6440364 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 @@ -148,28 +148,39 @@ const assertAllowedAssociatedTokenAccountProgramInstruction = async ( 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 + // When the ATA rent payer is the relay fee payer, the relay is absorbing + // the cost, so rate limiting applies. When the payer is the user's own + // wallet (funded via a USDC→SOL swap earlier in the tx), no rate limit + // is needed – the user is paying for their own token account. + const ataPayerIsRelayFeePayer = + options?.feePayer && + decodedInstruction.keys.payer.pubkey.toBase58() === options.feePayer + + if (ataPayerIsRelayFeePayer) { + 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, error.message) } - 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, error.message) } + // else: user-funded ATA (payer = user's root wallet) – no rate limit needed } else if ( matchingCreateInstructions.length !== matchingCloseInstructions.length ) { diff --git a/packages/web/package.json b/packages/web/package.json index 82775c916df..f684fdf9745 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", From 09c8e35d39a5c9b67254397119c26b0c891c6ef0 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 16 Mar 2026 17:30:11 -0700 Subject: [PATCH 2/8] Refine user-funded ATA creation: pre-create ATA directly, remove relay changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay's rate-limit logic is never hit because the destination ATA is now fully created before the main transfer transaction is built: - Rename prefundAtaCreationFromUsdc → createUserFundedAta, which does the USDC→SOL Jupiter swap AND creates the destination ATA directly from the root wallet (connection.sendRawTransaction, no relay involved) - Remove the unneeded createAssociatedToken instruction from the main transfer transaction, and restore the original signing logic - Revert assertRelayAllowedInstructions.ts and its test file (no relay changes needed) - Pin @coinflowlabs/react to exact version 5.9.1 (no ^ range) Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/audius-backend/solana.ts | 91 +++++++++++-------- .../assertRelayAllowedInstructions.test.ts | 24 ----- .../relay/assertRelayAllowedInstructions.ts | 51 ++++------- packages/web/package.json | 2 +- 4 files changed, 73 insertions(+), 95 deletions(-) diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index c2b2b4c7da1..d8bf7ecee1a 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -436,33 +436,38 @@ export const recoverUsdcFromRootWallet = async ({ } /** - * Swaps a small amount of USDC from the user's userbank to native SOL and - * delivers the SOL to `keypair.publicKey`. Used to pre-fund the user's root - * wallet so it can pay for destination ATA rent without going through the - * relay's rate-limited token-account-creation path. + * Creates a destination ATA funded by the user's own USDC: + * 1. Swaps a small USDC fee from the userbank to native SOL via Jupiter (relay pays gas), + * landing the SOL in the user's root wallet. + * 2. Uses that SOL to create the destination ATA directly from the root wallet, + * completely bypassing the relay and its token-account-creation rate limit. */ -const prefundAtaCreationFromUsdc = async ({ +const createUserFundedAta = async ({ sdk, connection, keypair, mint, - ethWallet + 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() - // How much SOL we need: ATA rent + small buffer for the surrounding tx fee + // SOL needed: ATA rent + buffer to cover the direct ATA creation tx fee const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_SIZE) const totalSolNeededLamports = rentExemptLamports + ATA_TX_FEE_BUFFER_LAMPORTS const totalSolNeededUi = totalSolNeededLamports / 1e9 - // Get a Jupiter quote: USDC → SOL, ExactOut so we receive exactly the rent amount + // ExactOut quote: receive exactly the rent amount, pay however much USDC it costs const { quoteResult: solQuote } = await getJupiterQuoteByMintWithRetry({ inputMint: mint.toBase58(), outputMint: SOL_MINT, @@ -475,12 +480,13 @@ const prefundAtaCreationFromUsdc = async ({ const feeAmountUsdc = BigInt(solQuote.inputAmount.amountString) console.debug( - `prefundAtaCreationFromUsdc: swapping ${feeAmountUsdc} USDC for ${totalSolNeededLamports} lamports` + `createUserFundedAta: swapping ${feeAmountUsdc} USDC for ${totalSolNeededLamports} lamports` ) + // --- TX 1 (via relay): USDC fee → native SOL in root wallet --- const prefundInstructions: TransactionInstruction[] = [] - // Create root wallet's temporary USDC ATA (idempotent; relay pays rent, reclaimed at end) + // Temporary USDC ATA for the root wallet (relay pays rent, reclaimed at end) const rootWalletUsdcAta = getAssociatedTokenAddressSync( mint, keypair.publicKey, @@ -495,7 +501,6 @@ const prefundAtaCreationFromUsdc = async ({ ) ) - // Transfer USDC fee from userbank to root wallet's USDC ATA const secpFeeInstruction = await sdk.services.claimableTokensClient.createTransferSecpInstruction({ amount: feeAmountUsdc, @@ -514,7 +519,7 @@ const prefundAtaCreationFromUsdc = async ({ }) prefundInstructions.push(transferFeeInstruction) - // Internal transfer memo (required for USDC transfers) + // USDC internal-transfer memo prefundInstructions.push( new TransactionInstruction({ keys: [{ pubkey: rootWalletUsdcAta, isSigner: false, isWritable: true }], @@ -523,7 +528,7 @@ const prefundAtaCreationFromUsdc = async ({ }) ) - // Jupiter swap: root wallet's USDC ATA → native SOL (unwrapped into keypair's wallet) + // Jupiter swap: USDC ATA → native SOL unwrapped into root wallet const swapRequest = { quoteResponse: solQuote.quote, userPublicKey: keypair.publicKey.toBase58(), @@ -545,12 +550,11 @@ const prefundAtaCreationFromUsdc = async ({ ]) prefundInstructions.push(swapInstruction) - // Close the temporary USDC ATA (returns relay's rent to fee payer) + // Close the temporary USDC ATA, returning relay's rent to fee payer prefundInstructions.push( createCloseAccountInstruction(rootWalletUsdcAta, feePayer, keypair.publicKey) ) - // Build, sign, and send via relay (relay pays gas; keypair signs as swap authority) const prefundTx = await sdk.services.solanaClient.buildTransaction({ feePayer, instructions: prefundInstructions, @@ -562,12 +566,31 @@ const prefundAtaCreationFromUsdc = async ({ const prefundSig = await sdk.services.solanaClient.sendTransaction(prefundTx, { skipPreflight: true }) - - // Wait for the SOL to land before the next transaction tries to spend it await connection.confirmTransaction(prefundSig, 'confirmed') - console.debug( - `prefundAtaCreationFromUsdc: pre-fund tx confirmed: ${prefundSig}` + console.debug(`createUserFundedAta: pre-fund tx confirmed: ${prefundSig}`) + + // --- TX 2 (direct, no relay): root wallet creates the destination ATA --- + // The relay never sees this transaction, so its rate limit is not involved. + const { blockhash } = await connection.getLatestBlockhash() + const createAtaTx = new Transaction({ + recentBlockhash: blockhash, + feePayer: keypair.publicKey + }) + createAtaTx.add( + createAssociatedTokenAccountIdempotentInstruction( + keypair.publicKey, + destination, + destinationWallet, + mint + ) ) + createAtaTx.sign(keypair) + const ataSig = await connection.sendRawTransaction( + createAtaTx.serialize(), + { skipPreflight: true } + ) + await connection.confirmTransaction(ataSig, 'confirmed') + console.debug(`createUserFundedAta: ATA creation confirmed: ${ataSig}`) } /** @@ -676,24 +699,19 @@ export const transferFromUserBank = async ({ ) if (keypair) { - // User-funded ATA creation: swap USDC to SOL via Jupiter so the user's - // root wallet can pay for the destination ATA rent, bypassing relay limits. - await prefundAtaCreationFromUsdc({ + // 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 + ethWallet, + destination, + destinationWallet }) - // Root wallet is now the ATA rent payer (relay only pays gas for the tx) - instructions.push( - createAssociatedTokenAccountIdempotentInstruction( - keypair.publicKey, - destination, - destinationWallet, - mint - ) - ) + // 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() @@ -746,13 +764,8 @@ export const transferFromUserBank = async ({ instructions }) - // Sign with keypair if creating user-funded ATA (as ATA rent payer) or as withdrawal signer - const signers = [signer, keypair && isCreatingTokenAccount ? keypair : null] - .filter((k): k is Keypair => k != null) - // Deduplicate in case signer === keypair - .filter((k, i, arr) => arr.findIndex(x => x.publicKey.equals(k.publicKey)) === i) - if (signers.length > 0) { - transaction.sign(signers) + if (signer) { + transaction.sign([signer]) } const signature = 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 a2821313231..143cc8d9521 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 @@ -224,30 +224,6 @@ describe('Solana Relay', function () { await assertRelayAllowedInstructions(complexInstructions) }) - it('should allow user-funded ATA creation without close (payer is not relay fee payer)', async function () { - const feePayer = getRandomPublicKey() - const userWallet = getRandomPublicKey() - const associatedToken = getRandomPublicKey() - const owner = getRandomPublicKey() - const wallet = 'some-eth-wallet' - - // ATA payer is the user's root wallet (not the relay fee payer) – no rate limit, no close needed - const instructions = [ - createAssociatedTokenAccountInstruction( - userWallet, // user's root wallet pays for ATA rent - associatedToken, - owner, - usdcMintKey - ) - ] - await assert.doesNotReject(async () => - assertRelayAllowedInstructions(instructions, { - user: { wallet, is_verified: false }, - feePayer: feePayer.toBase58() // different from userWallet - }) - ) - }) - it('should not allow create token account without close', async function () { const payer = getRandomPublicKey() const associatedToken = getRandomPublicKey() 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 26ab6440364..6bb23075e05 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 @@ -148,39 +148,28 @@ const assertAllowedAssociatedTokenAccountProgramInstruction = async ( wallet && matchingCreateInstructions.length !== matchingCloseInstructions.length ) { - // When the ATA rent payer is the relay fee payer, the relay is absorbing - // the cost, so rate limiting applies. When the payer is the user's own - // wallet (funded via a USDC→SOL swap earlier in the tx), no rate limit - // is needed – the user is paying for their own token account. - const ataPayerIsRelayFeePayer = - options?.feePayer && - decodedInstruction.keys.payer.pubkey.toBase58() === options.feePayer - - if (ataPayerIsRelayFeePayer) { - 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, error.message) + 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, error.message) } - // else: user-funded ATA (payer = user's root wallet) – no rate limit needed } else if ( matchingCreateInstructions.length !== matchingCloseInstructions.length ) { diff --git a/packages/web/package.json b/packages/web/package.json index f684fdf9745..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.9.1", + "@coinflowlabs/react": "5.9.1", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", From afa2838d85b29a18e56b56937ec1143b8143d1c5 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 16 Mar 2026 18:51:51 -0700 Subject: [PATCH 3/8] Fix ATA prefund swap: use ExactIn instruction for relay compatibility The relay's assertRelayAllowedInstructions only recognizes Jupiter route/sharedAccountsRoute discriminants (ExactIn). ExactOut produces exact_out_route/shared_accounts_exact_out_route, which the relay returns as 'unknown' and rejects with "Invalid relay instructions". Fix: get ExactOut quote to price the USDC fee accurately, then get an ExactIn quote for that same USDC amount to produce a relay-compatible swap instruction. Validate ExactIn output covers the required rent + fee buffer before proceeding. Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/audius-backend/solana.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/common/src/services/audius-backend/solana.ts b/packages/common/src/services/audius-backend/solana.ts index d8bf7ecee1a..3ece6368281 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -467,8 +467,9 @@ const createUserFundedAta = async ({ const totalSolNeededLamports = rentExemptLamports + ATA_TX_FEE_BUFFER_LAMPORTS const totalSolNeededUi = totalSolNeededLamports / 1e9 - // ExactOut quote: receive exactly the rent amount, pay however much USDC it costs - const { quoteResult: solQuote } = await getJupiterQuoteByMintWithRetry({ + // Step 1: ExactOut quote to determine how much USDC the user should pay. + // (ExactOut produces an exact_out_route instruction the relay doesn't recognize.) + const { quoteResult: costQuote } = await getJupiterQuoteByMintWithRetry({ inputMint: mint.toBase58(), outputMint: SOL_MINT, inputDecimals: USDC_DECIMALS, @@ -478,9 +479,30 @@ const createUserFundedAta = async ({ onlyDirectRoutes: false }) - const feeAmountUsdc = BigInt(solQuote.inputAmount.amountString) + const feeAmountUsdc = BigInt(costQuote.inputAmount.amountString) + + // Step 2: ExactIn quote using that USDC amount — produces a route/sharedAccountsRoute + // instruction that the relay recognizes and allows. + const feeAmountUsdcUi = Number(feeAmountUsdc) / 10 ** USDC_DECIMALS + 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 ${totalSolNeededLamports} lamports` + `createUserFundedAta: swapping ${feeAmountUsdc} USDC for ~${receivedLamports} lamports (need ${totalSolNeededLamports})` ) // --- TX 1 (via relay): USDC fee → native SOL in root wallet --- @@ -529,8 +551,9 @@ const createUserFundedAta = async ({ ) // Jupiter swap: USDC ATA → native SOL unwrapped into root wallet + // Uses ExactIn quote so the relay sees a route/sharedAccountsRoute instruction. const swapRequest = { - quoteResponse: solQuote.quote, + quoteResponse: swapQuote.quote, userPublicKey: keypair.publicKey.toBase58(), wrapAndUnwrapSol: true, dynamicSlippage: true From f274720aacc112c34c4472a2dcc634e220d71b35 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 17 Mar 2026 20:03:59 -0700 Subject: [PATCH 4/8] WIP, but not working --- packages/common/package.json | 2 +- packages/common/src/api/index.ts | 1 + .../common/src/api/tan-query/queryKeys.ts | 1 + .../wallets/useDestinationUsdcAccountCheck.ts | 88 +++++++++ .../src/api/tan-query/wallets/useSendCoins.ts | 6 +- .../common/src/messages/walletMessages.ts | 6 + .../src/services/audius-backend/solana.ts | 183 ++++++++---------- .../ui/modals/withdraw-usdc-modal/index.ts | 2 + .../relay/assertRelayAllowedInstructions.ts | 12 +- .../components/EnterTransferDetails.tsx | 131 +++++++++++-- 10 files changed, 312 insertions(+), 120 deletions(-) create mode 100644 packages/common/src/api/tan-query/wallets/useDestinationUsdcAccountCheck.ts 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 3ece6368281..9860e95b6e2 100644 --- a/packages/common/src/services/audius-backend/solana.ts +++ b/packages/common/src/services/audius-backend/solana.ts @@ -34,11 +34,17 @@ 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 @@ -465,155 +471,136 @@ const createUserFundedAta = async ({ const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_SIZE) const totalSolNeededLamports = rentExemptLamports + ATA_TX_FEE_BUFFER_LAMPORTS - const totalSolNeededUi = totalSolNeededLamports / 1e9 // Step 1: ExactOut quote to determine how much USDC the user should pay. + // Request slightly more SOL to absorb quote variance between cost quote and swap output. // (ExactOut produces an exact_out_route instruction the relay doesn't recognize.) + 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: totalSolNeededUi, + amountUi: costQuoteTargetLamports / 1e9, swapMode: 'ExactOut', onlyDirectRoutes: false }) const feeAmountUsdc = BigInt(costQuote.inputAmount.amountString) - - // Step 2: ExactIn quote using that USDC amount — produces a route/sharedAccountsRoute - // instruction that the relay recognizes and allows. const feeAmountUsdcUi = Number(feeAmountUsdc) / 10 ** USDC_DECIMALS - 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})` - ) - - // --- TX 1 (via relay): USDC fee → native SOL in root wallet --- - const prefundInstructions: TransactionInstruction[] = [] - // Temporary USDC ATA for the root wallet (relay pays rent, reclaimed at end) + // --- TX 1 (via relay): create ATA, transfer, swap, close --- + // Relay requires create and close in the same tx. + // Fetch swap quote fresh right before building to minimize staleness. const rootWalletUsdcAta = getAssociatedTokenAddressSync( mint, keypair.publicKey, true ) - prefundInstructions.push( + + const baseInstructions: TransactionInstruction[] = [ + // Create ATA for root wallet USDC createAssociatedTokenAccountIdempotentInstruction( feePayer, rootWalletUsdcAta, keypair.publicKey, mint - ) - ) - - const secpFeeInstruction = + ), + // Transfer USDC fee from user bank to the root wallet USDC ATA await sdk.services.claimableTokensClient.createTransferSecpInstruction({ amount: feeAmountUsdc, ethWallet, mint, destination: rootWalletUsdcAta, - instructionIndex: prefundInstructions.length - }) - prefundInstructions.push(secpFeeInstruction) - - const transferFeeInstruction = + instructionIndex: 1 + }), await sdk.services.claimableTokensClient.createTransferInstruction({ ethWallet, mint, destination: rootWalletUsdcAta - }) - prefundInstructions.push(transferFeeInstruction) - - // USDC internal-transfer memo - prefundInstructions.push( + }), + // Add memo to indicate internal transfer new TransactionInstruction({ keys: [{ pubkey: rootWalletUsdcAta, isSigner: false, isWritable: true }], programId: MEMO_PROGRAM_ID, data: Buffer.from(INTERNAL_TRANSFER_MEMO_STRING) }) - ) + ] - // Jupiter swap: USDC ATA → native SOL unwrapped into root wallet - // Uses ExactIn quote so the relay sees a route/sharedAccountsRoute instruction. - const swapRequest = { - quoteResponse: swapQuote.quote, - userPublicKey: keypair.publicKey.toBase58(), - wrapAndUnwrapSol: true, - dynamicSlippage: true - } - let swapInstructionsResult - try { - swapInstructionsResult = await jupiterInstance.swapInstructionsPost({ - swapRequest: { ...swapRequest, useSharedAccounts: true } - }) - } catch { - swapInstructionsResult = await jupiterInstance.swapInstructionsPost({ - swapRequest: { ...swapRequest, useSharedAccounts: false } - }) + // 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}` + ) } - const [swapInstruction] = convertJupiterInstructions([ - swapInstructionsResult.swapInstruction - ]) - prefundInstructions.push(swapInstruction) - - // Close the temporary USDC ATA, returning relay's rent to fee payer - prefundInstructions.push( - createCloseAccountInstruction(rootWalletUsdcAta, feePayer, keypair.publicKey) + console.debug( + `createUserFundedAta: swapping ${feeAmountUsdc} USDC for ~${receivedLamports} lamports (need ${totalSolNeededLamports})` ) - - const prefundTx = await sdk.services.solanaClient.buildTransaction({ - feePayer, - instructions: prefundInstructions, - addressLookupTables: swapInstructionsResult.addressLookupTableAddresses.map( - (addr: string) => new PublicKey(addr) - ) - }) - prefundTx.sign([keypair]) - const prefundSig = await sdk.services.solanaClient.sendTransaction(prefundTx, { - skipPreflight: true - }) - await connection.confirmTransaction(prefundSig, 'confirmed') - console.debug(`createUserFundedAta: pre-fund tx confirmed: ${prefundSig}`) - - // --- TX 2 (direct, no relay): root wallet creates the destination ATA --- - // The relay never sees this transaction, so its rate limit is not involved. - const { blockhash } = await connection.getLatestBlockhash() - const createAtaTx = new Transaction({ - recentBlockhash: blockhash, - feePayer: keypair.publicKey + const swapInstructionsResult = await jupiterInstance.swapInstructionsPost({ + swapRequest: { + quoteResponse: swapQuote.quote, + userPublicKey: keypair.publicKey.toBase58(), + payer: keypair.publicKey.toBase58(), + nativeDestinationAccount: keypair.publicKey.toBase58(), + dynamicSlippage: true + } }) - createAtaTx.add( + const jupiterSwap = convertJupiterInstructions([ + ...(swapInstructionsResult.otherInstructions ?? []), + ...(swapInstructionsResult.setupInstructions ?? []), + swapInstructionsResult.swapInstruction, + swapInstructionsResult.cleanupInstruction + ]) + + // Combine all instructions into a single transaction + const prefundInstructions = [ + ...baseInstructions, + ...jupiterSwap, createAssociatedTokenAccountIdempotentInstruction( keypair.publicKey, destination, destinationWallet, mint + ), + createCloseAccountInstruction( + rootWalletUsdcAta, + feePayer, + keypair.publicKey ) - ) - createAtaTx.sign(keypair) - const ataSig = await connection.sendRawTransaction( - createAtaTx.serialize(), + ] + + 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(ataSig, 'confirmed') - console.debug(`createUserFundedAta: ATA creation confirmed: ${ataSig}`) + await connection.confirmTransaction(prefundSig, 'confirmed') + console.debug( + `createUserFundedAta: prefund + ATA creation confirmed: ${prefundSig}` + ) } /** 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/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..fe6ae62fb2c 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 @@ -88,7 +88,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 +118,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 @@ -527,7 +534,8 @@ export const assertRelayAllowedInstructions = async ( i, instruction, instructions, - options?.user + options?.user, + options?.feePayer ) break case TOKEN_PROGRAM_ID.toBase58(): 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} )} - {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} )} - From e4759bc597278735e3e233f14cfd65021176f690 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 18 Mar 2026 11:46:37 -0700 Subject: [PATCH 8/8] Update lockfile --- package-lock.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66a1e1c5e34..d8d98ffde89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97869,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", @@ -97922,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", @@ -133787,7 +133793,7 @@ "@audius/harmony": "*", "@audius/sdk": "*", "@cloudflare/kv-asset-handler": "0.2.0", - "@coinflowlabs/react": "^5.9.1", + "@coinflowlabs/react": "5.9.1", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/server": "11.11.0",