Skip to content

Commit 95fe65e

Browse files
Add handle param to oauth/pay (#14039)
## Summary - Adds `handle` as an alternative to `recipient` in `/oauth/pay`, allowing callers to specify an Audius user handle instead of a raw Solana address - Resolves handle → user → derives user bank address client-side (pure PDA math, no RPC call) for instant UI rendering - Bundles user bank creation (if needed) + transfer into a single atomic Solana transaction at confirm time, eliminating the separate on-chain create tx - New `createUserBankIfNeededInstruction` method on `ClaimableTokensClient` returns the instruction without sending a tx, enabling callers to compose it into their own transactions - UI shows `@handle` with the derived address below when handle mode is used ## Test plan - [x] `/oauth/pay?handle=raymont&mint=...&amount=...` resolves and displays correctly - [x] `/oauth/pay?recipient=...&mint=...&amount=...` continues to work as before - [x] Providing both `handle` and `recipient` shows error - [x] Invalid handle shows appropriate error - [x] Payment succeeds in single transaction (user bank creation + transfer) - [x] Verify with user who has no existing user bank for the mint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95e55db commit 95fe65e

5 files changed

Lines changed: 214 additions & 45 deletions

File tree

packages/common/src/services/audius-backend/AudiusBackend.ts

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
} from '../../store'
4242
import { getErrorMessage, uuid, Maybe, Nullable } from '../../utils'
4343

44-
import { MintName, createUserBankIfNeeded } from './solana'
44+
import { MintName } from './solana'
4545

4646
type DisplayEncoding = 'utf8' | 'hex'
4747
type PhantomEvent = 'disconnect' | 'connect' | 'accountChanged'
@@ -990,47 +990,59 @@ export const audiusBackend = ({
990990
mint: PublicKey
991991
recipientEthAddress?: string // When provided, derives user-bank ATA for the recipient
992992
}) {
993-
let tokenAccountAddress: PublicKey
994-
995993
if (recipientEthAddress) {
996-
// When sending to a user, ensure their user-bank account exists
997-
// This will create it if needed (in a separate transaction)
998-
tokenAccountAddress = await createUserBankIfNeeded(sdk, {
999-
ethAddress: recipientEthAddress,
1000-
mint: mint as any,
1001-
recordAnalytics: () => {} // Analytics handled elsewhere
994+
// When sending to a user by ETH address, derive their user bank and
995+
// combine account creation (if needed) + transfer in a single tx.
996+
const { userBank, instruction: createInstruction } =
997+
await sdk.services.claimableTokensClient.createUserBankIfNeededInstruction(
998+
{
999+
ethWallet: recipientEthAddress,
1000+
mint: mint as any
1001+
}
1002+
)
1003+
1004+
const res = await transferTokens({
1005+
destination: userBank,
1006+
amount,
1007+
ethAddress,
1008+
sdk,
1009+
mint,
1010+
prefixInstructions: createInstruction ? [createInstruction] : []
10021011
})
1012+
return { res, error: null }
10031013
} else {
10041014
// When sending to a Solana wallet address directly, use regular ATA logic
1005-
tokenAccountAddress = await getOrCreateAssociatedTokenAccount({
1015+
const tokenAccountAddress = await getOrCreateAssociatedTokenAccount({
10061016
address,
10071017
sdk,
10081018
mint
10091019
})
1010-
}
10111020

1012-
const res = await transferTokens({
1013-
destination: tokenAccountAddress,
1014-
amount,
1015-
ethAddress,
1016-
sdk,
1017-
mint
1018-
})
1019-
return { res, error: null }
1021+
const res = await transferTokens({
1022+
destination: tokenAccountAddress,
1023+
amount,
1024+
ethAddress,
1025+
sdk,
1026+
mint
1027+
})
1028+
return { res, error: null }
1029+
}
10201030
}
10211031

10221032
async function transferTokens({
10231033
ethAddress,
10241034
destination,
10251035
amount,
10261036
sdk,
1027-
mint
1037+
mint,
1038+
prefixInstructions = []
10281039
}: {
10291040
ethAddress: string
10301041
destination: PublicKey
10311042
amount: AudioWei
10321043
sdk: AudiusSdkWithServices
10331044
mint: MintName | PublicKey
1045+
prefixInstructions?: import('@solana/web3.js').TransactionInstruction[]
10341046
}) {
10351047
console.info(
10361048
`Transferring ${amount.toString()} tokens with mint ${mint} to ${destination.toBase58()}`
@@ -1041,7 +1053,8 @@ export const audiusBackend = ({
10411053
amount,
10421054
ethWallet: ethAddress,
10431055
mint,
1044-
destination
1056+
destination,
1057+
instructionIndex: prefixInstructions.length
10451058
})
10461059
const transferInstruction =
10471060
await sdk.services.claimableTokensClient.createTransferInstruction({
@@ -1072,7 +1085,11 @@ export const audiusBackend = ({
10721085
}
10731086

10741087
const transaction = await sdk.services.solanaClient.buildTransaction({
1075-
instructions: [secpTransactionInstruction, transferInstruction],
1088+
instructions: [
1089+
...prefixInstructions,
1090+
secpTransactionInstruction,
1091+
transferInstruction
1092+
],
10761093
recentBlockhash
10771094
})
10781095
const signature =

packages/sdk/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,51 @@ export class ClaimableTokensClient {
182182
}
183183
}
184184

185+
/**
186+
* Returns a create-user-bank instruction if the user bank does not yet exist,
187+
* or null if it already does. Does NOT send a transaction — the caller is
188+
* expected to include the instruction in its own transaction.
189+
*/
190+
async createUserBankIfNeededInstruction(
191+
params: GetOrCreateUserBankRequest
192+
): Promise<{
193+
userBank: PublicKey
194+
instruction: ReturnType<
195+
typeof ClaimableTokensProgram.createAccountInstruction
196+
> | null
197+
}> {
198+
const args = await parseParams(
199+
'createUserBankIfNeededInstruction',
200+
GetOrCreateUserBankSchema
201+
)(params)
202+
const {
203+
ethWallet = await this.getDefaultWalletAddress(),
204+
feePayer: feePayerOverride
205+
} = args
206+
const mint = parseMint(args.mint, this.preconfiguredMints)
207+
const feePayer = feePayerOverride ?? (await this.client.getFeePayer())
208+
const userBank = await this.deriveUserBank(args)
209+
210+
const userBankAccount =
211+
await this.client.connection.getAccountInfo(userBank)
212+
if (!userBankAccount) {
213+
this.logger.debug(
214+
`User bank ${userBank} does not exist. Returning create instruction.`
215+
)
216+
const instruction = ClaimableTokensProgram.createAccountInstruction({
217+
ethAddress: ethWallet,
218+
payer: feePayer,
219+
mint,
220+
authority: this.deriveAuthority(mint),
221+
userBank,
222+
programId: this.programId
223+
})
224+
return { userBank, instruction }
225+
}
226+
this.logger.debug(`User bank ${userBank} already exists.`)
227+
return { userBank, instruction: null }
228+
}
229+
185230
/**
186231
* Creates a claimable tokens program transfer instruction using configured
187232
* program ID, mint addresses, derived nonce, and derived authorities.

packages/web/src/pages/oauth-pay-page/OAuthPayPage.tsx

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export const OAuthPayPage = () => {
3030

3131
const {
3232
recipient,
33+
handleUser,
34+
handleLoading,
35+
handleError,
3336
amount,
3437
mint,
3538
tokenInfo,
@@ -51,6 +54,8 @@ export const OAuthPayPage = () => {
5154
}
5255
})
5356

57+
const recipientDisplayName = handleUser ? `@${handleUser.handle}` : undefined
58+
5459
const formatAmount = (amount: bigint | null) => {
5560
if (!amount || !tokenInfo) return '0'
5661
return new FixedDecimal(amount, tokenInfo.decimals).toLocaleString(
@@ -74,10 +79,12 @@ export const OAuthPayPage = () => {
7479
!userHoldsMint ||
7580
isSubmitting ||
7681
!!queryParamsError ||
77-
!!error
82+
!!error ||
83+
handleLoading ||
84+
!!handleError
7885

7986
// Determine error message to show
80-
const displayError = queryParamsError || error
87+
const displayError = queryParamsError || handleError || error
8188

8289
// Auto-close success screen after 1 second
8390
useEffect(() => {
@@ -100,7 +107,7 @@ export const OAuthPayPage = () => {
100107
)
101108
}
102109

103-
if (balanceLoading || !tokenInfo) {
110+
if (balanceLoading || !tokenInfo || handleLoading) {
104111
return (
105112
<ContentWrapper display={display ?? 'popup'}>
106113
<Flex p='4xl' alignItems='center' justifyContent='center'>
@@ -143,14 +150,30 @@ export const OAuthPayPage = () => {
143150
<Text variant='heading' size='s' color='subdued'>
144151
{messages.recipient}
145152
</Text>
146-
<Text
147-
variant='body'
148-
size='m'
149-
color='default'
150-
css={{ wordBreak: 'break-all' }}
151-
>
152-
{recipient}
153-
</Text>
153+
{recipientDisplayName ? (
154+
<Flex direction='column' gap='xs'>
155+
<Text variant='body' size='m' color='default'>
156+
{recipientDisplayName}
157+
</Text>
158+
<Text
159+
variant='body'
160+
size='s'
161+
color='subdued'
162+
css={{ wordBreak: 'break-all' }}
163+
>
164+
{recipient}
165+
</Text>
166+
</Flex>
167+
) : (
168+
<Text
169+
variant='body'
170+
size='m'
171+
color='default'
172+
css={{ wordBreak: 'break-all' }}
173+
>
174+
{recipient}
175+
</Text>
176+
)}
154177
<PlainButton
155178
variant='subdued'
156179
css={{ alignSelf: 'flex-start' }}
@@ -236,13 +259,29 @@ export const OAuthPayPage = () => {
236259
<Text variant='heading' size='s' color='subdued'>
237260
{messages.recipient}
238261
</Text>
239-
<Text
240-
variant='body'
241-
size='l'
242-
css={{ wordBreak: 'break-all' }}
243-
>
244-
{recipient}
245-
</Text>
262+
{recipientDisplayName ? (
263+
<Flex direction='column' gap='xs'>
264+
<Text variant='body' size='l'>
265+
{recipientDisplayName}
266+
</Text>
267+
<Text
268+
variant='body'
269+
size='s'
270+
color='subdued'
271+
css={{ wordBreak: 'break-all' }}
272+
>
273+
{recipient}
274+
</Text>
275+
</Flex>
276+
) : (
277+
<Text
278+
variant='body'
279+
size='l'
280+
css={{ wordBreak: 'break-all' }}
281+
>
282+
{recipient}
283+
</Text>
284+
)}
246285
</Flex>
247286

248287
<Flex direction='column' gap='xs'>

0 commit comments

Comments
 (0)