From 6bb7bfcba37530b631beb3a45e97803b8e51f8c9 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 1 Apr 2026 23:11:45 -0700 Subject: [PATCH] Add handle param to oauth/pay for user-friendly payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support `handle` as an alternative to `recipient` in /oauth/pay. When provided, the handle is resolved to a user, their user bank address is derived client-side (no RPC), and account creation (if needed) is bundled into a single atomic transaction with the transfer at confirm time — eliminating the loading spinner from eager on-chain calls. Co-Authored-By: Claude Opus 4.6 --- .../services/audius-backend/AudiusBackend.ts | 61 +++++++++------ .../ClaimableTokensClient.ts | 45 +++++++++++ .../src/pages/oauth-pay-page/OAuthPayPage.tsx | 75 ++++++++++++++----- .../web/src/pages/oauth-pay-page/hooks.ts | 74 ++++++++++++++++-- .../web/src/pages/oauth-pay-page/messages.ts | 4 + 5 files changed, 214 insertions(+), 45 deletions(-) diff --git a/packages/common/src/services/audius-backend/AudiusBackend.ts b/packages/common/src/services/audius-backend/AudiusBackend.ts index 9698a3eefcd..180bcabbeda 100644 --- a/packages/common/src/services/audius-backend/AudiusBackend.ts +++ b/packages/common/src/services/audius-backend/AudiusBackend.ts @@ -41,7 +41,7 @@ import { } from '../../store' import { getErrorMessage, uuid, Maybe, Nullable } from '../../utils' -import { MintName, createUserBankIfNeeded } from './solana' +import { MintName } from './solana' type DisplayEncoding = 'utf8' | 'hex' type PhantomEvent = 'disconnect' | 'connect' | 'accountChanged' @@ -990,33 +990,43 @@ export const audiusBackend = ({ mint: PublicKey recipientEthAddress?: string // When provided, derives user-bank ATA for the recipient }) { - let tokenAccountAddress: PublicKey - if (recipientEthAddress) { - // When sending to a user, ensure their user-bank account exists - // This will create it if needed (in a separate transaction) - tokenAccountAddress = await createUserBankIfNeeded(sdk, { - ethAddress: recipientEthAddress, - mint: mint as any, - recordAnalytics: () => {} // Analytics handled elsewhere + // When sending to a user by ETH address, derive their user bank and + // combine account creation (if needed) + transfer in a single tx. + const { userBank, instruction: createInstruction } = + await sdk.services.claimableTokensClient.createUserBankIfNeededInstruction( + { + ethWallet: recipientEthAddress, + mint: mint as any + } + ) + + const res = await transferTokens({ + destination: userBank, + amount, + ethAddress, + sdk, + mint, + prefixInstructions: createInstruction ? [createInstruction] : [] }) + return { res, error: null } } else { // When sending to a Solana wallet address directly, use regular ATA logic - tokenAccountAddress = await getOrCreateAssociatedTokenAccount({ + const tokenAccountAddress = await getOrCreateAssociatedTokenAccount({ address, sdk, mint }) - } - const res = await transferTokens({ - destination: tokenAccountAddress, - amount, - ethAddress, - sdk, - mint - }) - return { res, error: null } + const res = await transferTokens({ + destination: tokenAccountAddress, + amount, + ethAddress, + sdk, + mint + }) + return { res, error: null } + } } async function transferTokens({ @@ -1024,13 +1034,15 @@ export const audiusBackend = ({ destination, amount, sdk, - mint + mint, + prefixInstructions = [] }: { ethAddress: string destination: PublicKey amount: AudioWei sdk: AudiusSdkWithServices mint: MintName | PublicKey + prefixInstructions?: import('@solana/web3.js').TransactionInstruction[] }) { console.info( `Transferring ${amount.toString()} tokens with mint ${mint} to ${destination.toBase58()}` @@ -1041,7 +1053,8 @@ export const audiusBackend = ({ amount, ethWallet: ethAddress, mint, - destination + destination, + instructionIndex: prefixInstructions.length }) const transferInstruction = await sdk.services.claimableTokensClient.createTransferInstruction({ @@ -1072,7 +1085,11 @@ export const audiusBackend = ({ } const transaction = await sdk.services.solanaClient.buildTransaction({ - instructions: [secpTransactionInstruction, transferInstruction], + instructions: [ + ...prefixInstructions, + secpTransactionInstruction, + transferInstruction + ], recentBlockhash }) const signature = diff --git a/packages/sdk/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts b/packages/sdk/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts index 9b2d4b1e846..8ca12d2dbd2 100644 --- a/packages/sdk/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts +++ b/packages/sdk/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts @@ -182,6 +182,51 @@ export class ClaimableTokensClient { } } + /** + * Returns a create-user-bank instruction if the user bank does not yet exist, + * or null if it already does. Does NOT send a transaction — the caller is + * expected to include the instruction in its own transaction. + */ + async createUserBankIfNeededInstruction( + params: GetOrCreateUserBankRequest + ): Promise<{ + userBank: PublicKey + instruction: ReturnType< + typeof ClaimableTokensProgram.createAccountInstruction + > | null + }> { + const args = await parseParams( + 'createUserBankIfNeededInstruction', + GetOrCreateUserBankSchema + )(params) + const { + ethWallet = await this.getDefaultWalletAddress(), + feePayer: feePayerOverride + } = args + const mint = parseMint(args.mint, this.preconfiguredMints) + const feePayer = feePayerOverride ?? (await this.client.getFeePayer()) + const userBank = await this.deriveUserBank(args) + + const userBankAccount = + await this.client.connection.getAccountInfo(userBank) + if (!userBankAccount) { + this.logger.debug( + `User bank ${userBank} does not exist. Returning create instruction.` + ) + const instruction = ClaimableTokensProgram.createAccountInstruction({ + ethAddress: ethWallet, + payer: feePayer, + mint, + authority: this.deriveAuthority(mint), + userBank, + programId: this.programId + }) + return { userBank, instruction } + } + this.logger.debug(`User bank ${userBank} already exists.`) + return { userBank, instruction: null } + } + /** * Creates a claimable tokens program transfer instruction using configured * program ID, mint addresses, derived nonce, and derived authorities. diff --git a/packages/web/src/pages/oauth-pay-page/OAuthPayPage.tsx b/packages/web/src/pages/oauth-pay-page/OAuthPayPage.tsx index 1c7c6f942a9..73b895e184a 100644 --- a/packages/web/src/pages/oauth-pay-page/OAuthPayPage.tsx +++ b/packages/web/src/pages/oauth-pay-page/OAuthPayPage.tsx @@ -30,6 +30,9 @@ export const OAuthPayPage = () => { const { recipient, + handleUser, + handleLoading, + handleError, amount, mint, tokenInfo, @@ -51,6 +54,8 @@ export const OAuthPayPage = () => { } }) + const recipientDisplayName = handleUser ? `@${handleUser.handle}` : undefined + const formatAmount = (amount: bigint | null) => { if (!amount || !tokenInfo) return '0' return new FixedDecimal(amount, tokenInfo.decimals).toLocaleString( @@ -74,10 +79,12 @@ export const OAuthPayPage = () => { !userHoldsMint || isSubmitting || !!queryParamsError || - !!error + !!error || + handleLoading || + !!handleError // Determine error message to show - const displayError = queryParamsError || error + const displayError = queryParamsError || handleError || error // Auto-close success screen after 1 second useEffect(() => { @@ -100,7 +107,7 @@ export const OAuthPayPage = () => { ) } - if (balanceLoading || !tokenInfo) { + if (balanceLoading || !tokenInfo || handleLoading) { return ( @@ -143,14 +150,30 @@ export const OAuthPayPage = () => { {messages.recipient} - - {recipient} - + {recipientDisplayName ? ( + + + {recipientDisplayName} + + + {recipient} + + + ) : ( + + {recipient} + + )} { {messages.recipient} - - {recipient} - + {recipientDisplayName ? ( + + + {recipientDisplayName} + + + {recipient} + + + ) : ( + + {recipient} + + )} diff --git a/packages/web/src/pages/oauth-pay-page/hooks.ts b/packages/web/src/pages/oauth-pay-page/hooks.ts index 63ad9850210..160931ee6c6 100644 --- a/packages/web/src/pages/oauth-pay-page/hooks.ts +++ b/packages/web/src/pages/oauth-pay-page/hooks.ts @@ -5,11 +5,14 @@ import { useCoinBalance, transformArtistCoinToTokenInfo, useSendCoins, - useCurrentAccountUser + useCurrentAccountUser, + useUserByHandle, + useQueryContext } from '@audius/common/api' import { useUserbank } from '@audius/common/hooks' import { SolanaWalletAddress } from '@audius/common/models' import { isValidSolAddress } from '@audius/common/store' +import { useQuery } from '@tanstack/react-query' import * as queryString from 'query-string' import { useLocation } from 'react-router' @@ -23,6 +26,7 @@ const useParsedPayParams = () => { const { recipient, + handle, amount, mint, state, @@ -61,6 +65,9 @@ const useParsedPayParams = () => { return null }, [origin]) + const hasHandle = handle && typeof handle === 'string' + const hasRecipient = recipient && typeof recipient === 'string' + const { error } = useMemo(() => { let error: string | null = null @@ -74,9 +81,14 @@ const useParsedPayParams = () => { responseMode !== 'fragment' ) { error = messages.responseModeError - } else if (!recipient || typeof recipient !== 'string') { + } else if (hasHandle && hasRecipient) { + error = messages.handleAndRecipientError + } else if (!hasHandle && !hasRecipient) { error = messages.missingParamsError - } else if (!isValidSolAddress(recipient as SolanaWalletAddress)) { + } else if ( + hasRecipient && + !isValidSolAddress(recipient as SolanaWalletAddress) + ) { error = messages.invalidRecipientError } else if (!amount || typeof amount !== 'string') { error = messages.missingParamsError @@ -101,6 +113,8 @@ const useParsedPayParams = () => { isRedirectValid, parsedOrigin, parsedRedirectUri, + hasHandle, + hasRecipient, recipient, amount, mint, @@ -112,6 +126,7 @@ const useParsedPayParams = () => { return { recipient: recipient as string | undefined, + handle: handle as string | undefined, amount: amount as string | undefined, mint: mint as string | undefined, state, @@ -132,7 +147,8 @@ export const useOAuthPaySetup = ({ onError: (errorMessage: string) => void }) => { const { - recipient, + recipient: recipientParam, + handle, amount, mint, state, @@ -147,6 +163,46 @@ export const useOAuthPaySetup = ({ const { data: account } = useCurrentAccountUser() const isLoggedIn = Boolean(account?.user_id) + // Resolve handle to user if provided + const { data: handleUser, isPending: handleUserPending } = useUserByHandle( + handle ?? null, + { enabled: !!handle } + ) + const recipientEthAddress = handle ? handleUser?.erc_wallet : undefined + + // Derive the recipient's user bank address (pure math, no RPC call). + // Actual account creation (if needed) happens at confirm time in a single tx. + const { audiusSdk } = useQueryContext() + const { + data: recipientUserBank, + isPending: recipientUserBankPending, + error: recipientUserBankError + } = useQuery({ + queryKey: ['deriveRecipientUserBank', recipientEthAddress, mint], + queryFn: async () => { + if (!recipientEthAddress || !mint) return null + const sdk = await audiusSdk() + const userBank = await sdk.services.claimableTokensClient.deriveUserBank({ + ethWallet: recipientEthAddress, + mint: mint as any + }) + return userBank.toBase58() + }, + enabled: !!recipientEthAddress && !!mint + }) + + // Determine the effective recipient address + const recipient = handle ? (recipientUserBank ?? undefined) : recipientParam + const handleLoading = + handle && (handleUserPending || recipientUserBankPending) + const handleError = handle + ? !handleUserPending && !handleUser + ? messages.handleNotFoundError + : recipientUserBankError + ? messages.handleResolvingError + : null + : null + // Get the user-bank address for the specific mint (the one used to send tokens) const { userBankAddress } = useUserbank(mint ?? undefined) const currentUserWallet = userBankAddress @@ -285,6 +341,8 @@ export const useOAuthPaySetup = ({ const { signature } = await sendCoinsMutation.mutateAsync({ recipientWallet: recipient as SolanaWalletAddress, amount: amountBigInt, + recipientEthAddress, + recipientHandle: handle, source: 'oauth_pay_page' }) @@ -305,7 +363,9 @@ export const useOAuthPaySetup = ({ hasSufficientBalance, formResponseAndPostMessage, sendCoinsMutation, - onError + onError, + recipientEthAddress, + handle ]) // Handle closing after success screen is shown @@ -333,6 +393,10 @@ export const useOAuthPaySetup = ({ return { recipient, + handle, + handleUser, + handleLoading: !!handleLoading, + handleError, amount: amountBigInt, mint, state, diff --git a/packages/web/src/pages/oauth-pay-page/messages.ts b/packages/web/src/pages/oauth-pay-page/messages.ts index 1a33bfb20a1..f7bf0271212 100644 --- a/packages/web/src/pages/oauth-pay-page/messages.ts +++ b/packages/web/src/pages/oauth-pay-page/messages.ts @@ -11,8 +11,12 @@ export const messages = { insufficientBalance: 'Insufficient balance', missingParamsError: 'Whoops, this is an invalid link (missing required parameters).', + handleAndRecipientError: + 'Whoops, this is an invalid link (cannot specify both handle and recipient).', invalidRecipientError: 'Whoops, this is an invalid link (recipient address is invalid).', + handleNotFoundError: 'Could not find a user with that handle.', + handleResolvingError: 'Failed to resolve user bank for the given handle.', invalidAmountError: 'Whoops, this is an invalid link (amount is invalid).', missingMintError: 'Whoops, this is an invalid link (mint address is missing).',