diff --git a/packages/sdk/src/sdk/createSdk.ts b/packages/sdk/src/sdk/createSdk.ts index 3e0253af007..b29e9c72e5e 100644 --- a/packages/sdk/src/sdk/createSdk.ts +++ b/packages/sdk/src/sdk/createSdk.ts @@ -27,9 +27,11 @@ import { productionConfig } from './config/production' import { addAppInfoMiddleware, addRequestSignatureMiddleware, + addSolanaWalletSignatureMiddleware, addTokenRefreshMiddleware } from './middleware' import { OAuth } from './oauth' +import { SolanaWallet } from './solanaWallet' import { TokenStoreLocalStorage } from './oauth/TokenStoreLocalStorage' import { Logger, Storage, StorageNodeSelector } from './services' import { SdkConfigSchema, type SdkConfig } from './types' @@ -72,6 +74,8 @@ export const createSdk = (config: SdkConfig) => { openUrl: services?.openUrl }) + const solanaWallet = new SolanaWallet() + if (apiSecret || services?.audiusWalletClient) { middleware.push( addRequestSignatureMiddleware({ @@ -99,6 +103,8 @@ export const createSdk = (config: SdkConfig) => { ) } + middleware.push(addSolanaWalletSignatureMiddleware({ solanaWallet })) + // Auto-refresh middleware — intercepts 401s and retries with a fresh token. if (apiKey && oauth) { middleware.push( @@ -137,6 +143,7 @@ export const createSdk = (config: SdkConfig) => { return { oauth, + solanaWallet, tokenStore, tracks: new TracksApi(apiConfig), users: usersApi, diff --git a/packages/sdk/src/sdk/createSdkWithServices.ts b/packages/sdk/src/sdk/createSdkWithServices.ts index 161ca92e34e..0a6bfcb6c99 100644 --- a/packages/sdk/src/sdk/createSdkWithServices.ts +++ b/packages/sdk/src/sdk/createSdkWithServices.ts @@ -31,6 +31,7 @@ import { productionConfig } from './config/production' import { addAppInfoMiddleware, addRequestSignatureMiddleware, + addSolanaWalletSignatureMiddleware, addTokenRefreshMiddleware } from './middleware' import { OAuth } from './oauth' @@ -70,6 +71,7 @@ import { StorageNodeSelector, getDefaultStorageNodeSelectorConfig } from './services/StorageNodeSelector' +import { SolanaWallet } from './solanaWallet' import { SdkConfig, SdkConfigSchema, ServicesContainer } from './types' import fetch from './utils/fetch' @@ -351,6 +353,8 @@ const initializeApis = ({ : productionConfig.network.apiEndpoint const basePath = `${apiEndpoint}/v1` + const solanaWallet = new SolanaWallet() + const middleware = [ addAppInfoMiddleware({ apiKey, @@ -362,7 +366,8 @@ const initializeApis = ({ services, apiKey, apiSecret - }) + }), + addSolanaWalletSignatureMiddleware({ solanaWallet }) ] // Token store for PKCE flow — provides dynamic accessToken to Configuration @@ -453,6 +458,7 @@ const initializeApis = ({ return { oauth, + solanaWallet, tokenStore, tracks, users, diff --git a/packages/sdk/src/sdk/index.ts b/packages/sdk/src/sdk/index.ts index 0f57ef476e8..d025b6cec76 100644 --- a/packages/sdk/src/sdk/index.ts +++ b/packages/sdk/src/sdk/index.ts @@ -59,6 +59,7 @@ export * from './services' export { productionConfig } from './config/production' export { developmentConfig } from './config/development' export * from './oauth/types' +export * from './solanaWallet' export { ParseRequestError } from './utils/parseParams' export * from './utils/rendezvous' export * as Errors from './utils/errors' diff --git a/packages/sdk/src/sdk/middleware/addSolanaWalletSignatureMiddleware.ts b/packages/sdk/src/sdk/middleware/addSolanaWalletSignatureMiddleware.ts new file mode 100644 index 00000000000..e14881fa14e --- /dev/null +++ b/packages/sdk/src/sdk/middleware/addSolanaWalletSignatureMiddleware.ts @@ -0,0 +1,44 @@ +import type { + FetchParams, + Middleware, + RequestContext +} from '../api/generated/default' +import type { SolanaWallet } from '../solanaWallet' + +/** + * Injects X-Solana-* headers when a wallet credential is set. + * Works alongside OAuth — both can be present so the API merges balances. + * + * @example + * ```ts + * import { sdk as audiusSdk } from '@audius/sdk' + * + * const sdk = audiusSdk({ appName: 'MyApp' }) + * await sdk.solanaWallet.auth(window.solana) + * sdk.tracks.getTrack({ id: '123' }) + * ``` + */ +export const addSolanaWalletSignatureMiddleware = ({ + solanaWallet +}: { + solanaWallet: SolanaWallet +}): Middleware => ({ + pre: async (context: RequestContext): Promise => { + const credential = solanaWallet.getCredential() + if (!credential) return context + + const headers = context.init.headers as Record + return { + ...context, + init: { + ...context.init, + headers: { + ...headers, + 'X-Solana-Wallet': credential.publicKey, + 'X-Solana-Message': credential.message, + 'X-Solana-Signature': credential.signature + } + } + } + } +}) diff --git a/packages/sdk/src/sdk/middleware/index.ts b/packages/sdk/src/sdk/middleware/index.ts index ccd0647d27d..fe0caaa2244 100644 --- a/packages/sdk/src/sdk/middleware/index.ts +++ b/packages/sdk/src/sdk/middleware/index.ts @@ -1,3 +1,4 @@ export { addAppInfoMiddleware } from './addAppInfoMiddleware' export { addRequestSignatureMiddleware } from './addRequestSignatureMiddleware' +export { addSolanaWalletSignatureMiddleware } from './addSolanaWalletSignatureMiddleware' export { addTokenRefreshMiddleware } from './addTokenRefreshMiddleware' diff --git a/packages/sdk/src/sdk/solanaWallet/SolanaWallet.ts b/packages/sdk/src/sdk/solanaWallet/SolanaWallet.ts new file mode 100644 index 00000000000..480c4c5252d --- /dev/null +++ b/packages/sdk/src/sdk/solanaWallet/SolanaWallet.ts @@ -0,0 +1,61 @@ +import bs58 from 'bs58' + +export type SolanaWalletCredential = { + publicKey: string + message: string + signature: string +} + +/** + * Minimal interface for a Solana wallet provider (Phantom, Solflare, etc.). + * Apps pass their connected wallet to `SolanaWallet.auth()`. + */ +export type SolanaWalletProvider = { + connect(): Promise<{ publicKey: { toString(): string } }> + signMessage( + message: Uint8Array, + encoding: string + ): Promise<{ signature: Uint8Array }> +} + +export function createSolanaWalletSignatureMessage() { + const timestamp = Date.now() + const message = `audius:solana-wallet:${timestamp}` + const messageBytes = new TextEncoder().encode(message) + return { message, messageBytes, timestamp } +} + +export class SolanaWallet { + private credential: SolanaWalletCredential | null = null + + async auth(provider: SolanaWalletProvider) { + const { publicKey } = await provider.connect() + const { message, messageBytes } = createSolanaWalletSignatureMessage() + const { signature: sigBytes } = await provider.signMessage( + messageBytes, + 'utf8' + ) + this.credential = { + publicKey: publicKey.toString(), + message, + signature: bs58.encode(sigBytes) + } + return { publicKey: this.credential.publicKey } + } + + setCredential(credential: SolanaWalletCredential) { + this.credential = credential + } + + clearCredential() { + this.credential = null + } + + getCredential(): SolanaWalletCredential | null { + return this.credential + } + + isAuthenticated(): boolean { + return this.credential !== null + } +} diff --git a/packages/sdk/src/sdk/solanaWallet/index.ts b/packages/sdk/src/sdk/solanaWallet/index.ts new file mode 100644 index 00000000000..d2017d52143 --- /dev/null +++ b/packages/sdk/src/sdk/solanaWallet/index.ts @@ -0,0 +1,8 @@ +export { + SolanaWallet, + createSolanaWalletSignatureMessage +} from './SolanaWallet' +export type { + SolanaWalletCredential, + SolanaWalletProvider +} from './SolanaWallet' diff --git a/packages/web/examples/coin-gated/src/App.tsx b/packages/web/examples/coin-gated/src/App.tsx index 1e207df185e..94a308b542e 100644 --- a/packages/web/examples/coin-gated/src/App.tsx +++ b/packages/web/examples/coin-gated/src/App.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import bs58 from 'bs58' import { config } from './config' import { getSDK } from './sdk' @@ -9,12 +8,6 @@ import { getSDK } from './sdk' // Types // --------------------------------------------------------------------------- -type SolanaWalletAuth = { - pubkey: string - message: string - signature: string // base58-encoded ed25519 signature -} - type UserProfile = { id?: string handle?: string @@ -34,19 +27,15 @@ type TrackItem = { } // --------------------------------------------------------------------------- -// Phantom / Solana wallet adapter +// Phantom wallet detection // --------------------------------------------------------------------------- -interface PhantomProvider { - isPhantom?: boolean - connect(): Promise<{ publicKey: { toBytes(): Uint8Array; toString(): string } }> - signMessage(message: Uint8Array, encoding: string): Promise<{ signature: Uint8Array }> - disconnect(): Promise -} - -function getPhantom(): PhantomProvider | null { +function getPhantom() { if (typeof window !== 'undefined' && 'solana' in window) { - const provider = (window as Record).solana as PhantomProvider + const provider = (window as Record).solana as { + isPhantom?: boolean + disconnect(): Promise + } if (provider?.isPhantom) return provider } return null @@ -82,7 +71,6 @@ function useCoinBalance( queryKey: ['coin-balance', userId ? 'user' : 'wallet', userId ?? walletAddress, coinMint], queryFn: async () => { if (userId) { - // Single-coin lookup by user ID + mint — no need to fetch all coins const res = await sdk.users.getUserCoin({ id: userId, mint: coinMint! }) const coin = res.data as { decimals?: number; balance?: number } | undefined if (!coin) return null @@ -90,7 +78,6 @@ function useCoinBalance( const rawBalance = coin.balance ?? 0 return rawBalance / Math.pow(10, decimals) } - // Wallet path: no single-coin endpoint, fetch all and filter const res = await sdk.wallets.getWalletCoins({ walletId: walletAddress! }) const match = (res.data ?? []).find( (c: { mint?: string }) => c.mint === coinMint @@ -108,10 +95,10 @@ function useCoinBalance( // Hooks: useGatedTracks // --------------------------------------------------------------------------- -function useGatedTracks(artistId: string | undefined, userId: string | undefined) { +function useGatedTracks(artistId: string | undefined, userId: string | undefined, walletConnected: boolean) { const sdk = getSDK() return useQuery({ - queryKey: ['gated-tracks', artistId, userId], + queryKey: ['gated-tracks', artistId, userId, walletConnected], queryFn: async () => { const res = await sdk.users.getTracksByUser({ id: artistId!, @@ -124,44 +111,6 @@ function useGatedTracks(artistId: string | undefined, userId: string | undefined }) } -// --------------------------------------------------------------------------- -// Stream helpers -// --------------------------------------------------------------------------- - -async function streamWithOAuth(trackId: string, userId: string): Promise { - // Use the generated streamTrack method which goes through the SDK middleware - // pipeline (adds Bearer token automatically). With noRedirect, the API - // returns a JSON object { data: "https://content-node/..." } instead of 302. - const sdk = getSDK() - const res = await sdk.tracks.streamTrack({ trackId, userId, noRedirect: true }) - // res.data is the direct content node URL — use it as the audio src - return res.data -} - -async function streamWithWallet( - trackId: string, - wallet: SolanaWalletAuth -): Promise { - // Build the stream URL manually and add Solana wallet headers. - // The middleware on the API verifies the ed25519 signature and checks - // on-chain token balances in real time. - const sdk = getSDK() - // Get the base URL from the SDK's configuration - const base = (sdk.tracks as unknown as { configuration: { basePath: string } }) - .configuration.basePath - const url = `${base}/tracks/${encodeURIComponent(trackId)}/stream?no_redirect=true` - const res = await fetch(url, { - headers: { - 'X-Solana-Wallet': wallet.pubkey, - 'X-Solana-Message': wallet.message, - 'X-Solana-Signature': wallet.signature - } - }) - if (!res.ok) throw new Error(`Stream failed: ${res.status}`) - const json = await res.json() - return json.data -} - // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- @@ -173,7 +122,8 @@ export default function App() { // Auth state const [profile, setProfile] = useState(null) - const [solWallet, setSolWallet] = useState(null) + const [walletConnected, setWalletConnected] = useState(false) + const [walletPubkey, setWalletPubkey] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -181,6 +131,7 @@ export default function App() { const [playingId, setPlayingId] = useState(null) const [streamLoading, setStreamLoading] = useState(false) const audioRef = useRef(null) + // Data const { data: coin, isPending: coinPending, error: coinError } = useCoin(activeTicker) const userId = profile?.id ? String(profile.id) : undefined @@ -189,11 +140,10 @@ export default function App() { data: tracks, isPending: tracksPending, error: tracksError - } = useGatedTracks(artistId, userId) + } = useGatedTracks(artistId, userId, walletConnected) - // Balance for the active coin — works for both auth paths const coinMint = coin?.mint - const { data: coinBalance } = useCoinBalance(userId, solWallet?.pubkey, coinMint) + const { data: coinBalance } = useCoinBalance(userId, walletPubkey ?? undefined, coinMint) // ------------------------------------------------------------------------- // OAuth session restore @@ -252,28 +202,27 @@ export default function App() { const handleConnectWallet = useCallback(async () => { setError(null) - const phantom = getPhantom() - if (!phantom) { + if (!getPhantom()) { setError('Phantom wallet not found. Install phantom.app to use wallet sign-in.') return } try { - const { publicKey } = await phantom.connect() - const pubkey = publicKey.toString() - const message = `Audius coin-gated access: ${Date.now()}` - const msgBytes = new TextEncoder().encode(message) - const { signature: sigBytes } = await phantom.signMessage(msgBytes, 'utf8') - const signature = bs58.encode(sigBytes) - setSolWallet({ pubkey, message, signature }) + const sdk = getSDK() + const { publicKey } = await sdk.solanaWallet.auth(window.solana) + setWalletConnected(true) + setWalletPubkey(publicKey) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Wallet connection failed') } }, []) const handleDisconnectWallet = useCallback(async () => { + const sdk = getSDK() const phantom = getPhantom() if (phantom) await phantom.disconnect().catch(() => {}) - setSolWallet(null) + sdk.solanaWallet.clearCredential() + setWalletConnected(false) + setWalletPubkey(null) }, []) // ------------------------------------------------------------------------- @@ -288,7 +237,6 @@ export default function App() { const handlePlay = useCallback( async (trackId: string) => { - // Toggle off if (playingId === trackId) { cleanupAudio() setPlayingId(null) @@ -300,20 +248,22 @@ export default function App() { setError(null) try { - let streamUrl: string - if (profile?.id) { - streamUrl = await streamWithOAuth(trackId, String(profile.id)) - } else if (solWallet) { - streamUrl = await streamWithWallet(trackId, solWallet) - } else { + if (!profile?.id && !walletConnected) { setError('Sign in with Audius or connect a Solana wallet to stream.') setStreamLoading(false) return } + const sdk = getSDK() + const res = await sdk.tracks.streamTrack({ + trackId, + userId, + noRedirect: true + }) + if (!audioRef.current) audioRef.current = new Audio() const audio = audioRef.current - audio.src = streamUrl + audio.src = res.data audio.onended = () => { setPlayingId(null) cleanupAudio() @@ -331,7 +281,7 @@ export default function App() { setStreamLoading(false) } }, - [playingId, profile, solWallet, cleanupAudio] + [playingId, profile, walletConnected, userId, cleanupAudio] ) // ------------------------------------------------------------------------- @@ -359,7 +309,7 @@ export default function App() { // ------------------------------------------------------------------------- // Render: main // ------------------------------------------------------------------------- - const isAuthed = !!profile || !!solWallet + const isAuthed = !!profile || walletConnected const coinTicker = coin?.ticker ?? activeTicker return ( @@ -443,7 +393,7 @@ export default function App() { )} - {!solWallet ? ( + {!walletConnected ? (