Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PAY-2912] Add purchase album support to SDK #8593

Merged
merged 4 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-pens-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@audius/sdk': minor
---

Add sdk.albums.purchase()
36 changes: 36 additions & 0 deletions packages/commands/src/purchase-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,39 @@ program.command('purchase-track')
}
process.exit(0)
})

program.command('purchase-album')
.description('Buys an album using USDC')
.argument('<id>', 'The album ID')
.argument('<price>', 'The expected price of the album', parseFloat)
.option('-f, --from [from]', 'The account purchasing the content (handle)')
.option(
'-e, --extra-amount [amount]',
'Extra amount to pay in addition to the price (in dollars)'
, parseFloat)
.action(async (id, price, { from, extraAmount }) => {
const audiusLibs = await initializeAudiusLibs(from)
const userIdNumber = audiusLibs.userStateManager.getCurrentUserId()
const userId = Utils.encodeHashId(userIdNumber)
const albumId = Utils.encodeHashId(id)

// extract privkey and pubkey from hedgehog
// only works with accounts created via audius-cmd
const wallet = audiusLibs?.hedgehog?.getWallet()
const privKey = wallet?.getPrivateKeyString()
const pubKey = wallet?.getAddressString()

// init sdk with priv and pub keys as api keys and secret
// this enables writes via sdk
const audiusSdk = await initializeAudiusSdk({ apiKey: pubKey, apiSecret: privKey })

try {
console.log('Purchasing album...', { albumId, userId, price, extraAmount })
const response = await audiusSdk.albums.purchase({ albumId, userId, price, extraAmount })
console.log(chalk.green('Successfully purchased album'))
console.log(chalk.yellow('Transaction Signature:'), response)
} catch (err) {
program.error(err)
}
process.exit(0)
})
12 changes: 9 additions & 3 deletions packages/discovery-provider/src/api/v1/models/playlists.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from flask_restx import fields

from src.api.v1.models.access_gate import access_gate
from src.api.v1.models.extensions.fields import NestedOneOf
from src.api.v1.models.tracks import track_full
from src.api.v1.models.users import user_model, user_model_full

Expand Down Expand Up @@ -30,6 +32,7 @@
playlist_model = ns.model(
"playlist",
{
"blocknumber": fields.Integer(required=True),
"artwork": fields.Nested(playlist_artwork, allow_null=True),
"description": fields.String,
"permalink": fields.String,
Expand All @@ -47,14 +50,19 @@
"ddex_app": fields.String(allow_null=True),
"access": fields.Nested(access),
"upc": fields.String(allow_null=True),
"is_stream_gated": fields.Boolean(required=True),
"stream_conditions": NestedOneOf(
access_gate,
allow_null=True,
description="How to unlock stream access to the track",
),
},
)

full_playlist_without_tracks_model = ns.clone(
"playlist_full_without_tracks",
playlist_model,
{
"blocknumber": fields.Integer(required=True),
"created_at": fields.String,
"followee_reposts": fields.List(fields.Nested(repost), required=True),
"followee_favorites": fields.List(fields.Nested(favorite), required=True),
Expand All @@ -73,8 +81,6 @@
"cover_art_sizes": fields.String,
"cover_art_cids": fields.Nested(playlist_artwork, allow_null=True),
"track_count": fields.Integer(required=True),
"is_stream_gated": fields.Boolean(required=True),
"stream_conditions": fields.Raw(allow_null=True),
},
)

Expand Down
198 changes: 194 additions & 4 deletions packages/libs/src/sdk/api/albums/AlbumsApi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import type { AuthService, StorageService } from '../../services'
import { USDC } from '@audius/fixed-decimal'

import type {
AuthService,
ClaimableTokensClient,
PaymentRouterClient,
StorageService
} from '../../services'
import type {
EntityManagerService,
AdvancedOptions
} from '../../services/EntityManager/types'
import type { LoggerService } from '../../services/Logger'
import { parseParams } from '../../utils/parseParams'
import type { Configuration } from '../generated/default'
import {
instanceOfPurchaseGate,
UsdcGate,
type Configuration
} from '../generated/default'
import { PlaylistsApi } from '../playlists/PlaylistsApi'

import {
Expand All @@ -17,6 +28,8 @@ import {
FavoriteAlbumSchema,
getAlbumRequest,
getAlbumTracksRequest,
PurchaseAlbumRequest,
PurchaseAlbumSchema,
RepostAlbumRequest,
RepostAlbumSchema,
UnfavoriteAlbumRequest,
Expand All @@ -33,8 +46,10 @@ export class AlbumsApi {
configuration: Configuration,
storage: StorageService,
entityManager: EntityManagerService,
auth: AuthService,
logger: LoggerService
private auth: AuthService,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh wow TIL you could even do this here

private logger: LoggerService,
private claimableTokensClient: ClaimableTokensClient,
private paymentRouterClient: PaymentRouterClient
) {
this.playlistsApi = new PlaylistsApi(
configuration,
Expand Down Expand Up @@ -214,4 +229,179 @@ export class AlbumsApi {
advancedOptions
)
}

/**
* Purchases stream access to an album
*
* @hidden
*/
async purchase(params: PurchaseAlbumRequest) {
const {
userId,
albumId,
price: priceNumber,
extraAmount: extraAmountNumber = 0,
walletAdapter
} = await parseParams('purchase', PurchaseAlbumSchema)(params)

const contentType = 'album'
const mint = 'USDC'

// Fetch album
this.logger.debug('Fetching album...', { albumId })
const { data: albums } = await this.getAlbum({
userId: params.userId, // not sure why this is required
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
albumId: params.albumId // use hashed albumId
})

const album = albums ? albums[0] : undefined

// Validate purchase attempt
if (!album) {
throw new Error('Album not found.')
}

if (!album.isStreamGated) {
throw new Error('Attempted to purchase free album.')
}

if (album.user.id === params.userId) {
throw new Error('Attempted to purchase own album.')
}

let numberSplits: UsdcGate['splits'] = {}
let centPrice: number
const accessType: 'stream' | 'download' = 'stream'

// Get conditions
if (
album.streamConditions &&
instanceOfPurchaseGate(album.streamConditions)
) {
centPrice = album.streamConditions.usdcPurchase.price
numberSplits = album.streamConditions.usdcPurchase.splits
} else {
this.logger.debug(album.streamConditions)
throw new Error('Album is not available for purchase.')
}

// Check if already purchased
if (accessType === 'stream' && album.access?.stream) {
throw new Error('Album already purchased')
}

// Check if price changed
if (USDC(priceNumber).value < USDC(centPrice / 100).value) {
throw new Error('Track price increased.')
}

let extraAmount = USDC(extraAmountNumber).value
const total = USDC(centPrice / 100.0).value + extraAmount
this.logger.debug('Purchase total:', total)

// Convert splits to big int and spread extra amount to every split
const splits = Object.entries(numberSplits).reduce(
(prev, [key, value], index, arr) => {
const amountToAdd = extraAmount / BigInt(arr.length - index)
extraAmount = USDC(extraAmount - amountToAdd).value
return {
...prev,
[key]: BigInt(value) + amountToAdd
}
},
{}
)
this.logger.debug('Calculated splits after extra amount:', splits)

// Create user bank for recipient if not exists
this.logger.debug('Checking for recipient user bank...')
const { userBank: recipientUserBank, didExist } =
await this.claimableTokensClient.getOrCreateUserBank({
ethWallet: album.user.wallet,
mint: 'USDC'
})
if (!didExist) {
this.logger.debug('Created user bank', {
recipientUserBank: recipientUserBank.toBase58()
})
} else {
this.logger.debug('User bank exists', {
recipientUserBank: recipientUserBank.toBase58()
})
}

const routeInstruction =
await this.paymentRouterClient.createRouteInstruction({
splits,
total,
mint
})
const memoInstruction =
await this.paymentRouterClient.createPurchaseMemoInstruction({
contentId: albumId,
contentType,
blockNumber: album.blocknumber,
buyerUserId: userId,
accessType
})

if (walletAdapter) {
this.logger.debug('Using connected wallet to purchase...')
if (!walletAdapter.publicKey) {
throw new Error('Could not get connected wallet address')
}
// Use the specified Solana wallet
const transferInstruction =
await this.paymentRouterClient.createTransferInstruction({
sourceWallet: walletAdapter.publicKey,
total,
mint
})
const transaction = await this.paymentRouterClient.buildTransaction({
feePayer: walletAdapter.publicKey,
instructions: [transferInstruction, routeInstruction, memoInstruction]
})
return await walletAdapter.sendTransaction(
transaction,
this.paymentRouterClient.connection
)
} else {
// Use the authed wallet's userbank and relay
const ethWallet = await this.auth.getAddress()
this.logger.debug(
`Using userBank ${await this.claimableTokensClient.deriveUserBank({
ethWallet,
mint: 'USDC'
})} to purchase...`
)
const paymentRouterTokenAccount =
await this.paymentRouterClient.getOrCreateProgramTokenAccount({
mint
})

const transferSecpInstruction =
await this.claimableTokensClient.createTransferSecpInstruction({
ethWallet,
destination: paymentRouterTokenAccount.address,
mint,
amount: total,
auth: this.auth
})
const transferInstruction =
await this.claimableTokensClient.createTransferInstruction({
ethWallet,
destination: paymentRouterTokenAccount.address,
mint
})
const transaction = await this.paymentRouterClient.buildTransaction({
instructions: [
transferSecpInstruction,
transferInstruction,
routeInstruction,
memoInstruction
]
})
return await this.paymentRouterClient.sendTransaction(transaction)
}
}
}
26 changes: 26 additions & 0 deletions packages/libs/src/sdk/api/albums/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WalletAdapter } from '@solana/wallet-adapter-base'
import { z } from 'zod'

import { DDEXResourceContributor, DDEXCopyright } from '../../types/DDEX'
Expand Down Expand Up @@ -148,3 +149,28 @@ export const UnrepostAlbumSchema = z
.strict()

export type UnrepostAlbumRequest = z.input<typeof UnrepostAlbumSchema>

export const PurchaseAlbumSchema = z
.object({
/** The ID of the user purchasing the album. */
userId: HashId,
/** The ID of the album to purchase. */
albumId: HashId,
/**
* The price of the album at the time of purchase (in dollars if number, USDC if bigint).
* Used to check against current album price in case it changed,
* effectively setting a "max price" for the purchase.
*/
price: z.union([z.number().min(0), z.bigint().min(BigInt(0))]),
/** Any extra amount the user wants to donate (in dollars if number, USDC if bigint) */
extraAmount: z
.union([z.number().min(0), z.bigint().min(BigInt(0))])
.optional(),
/** A wallet to use to purchase (defaults to the authed user's user bank if not specified) */
walletAdapter: z
.custom<Pick<WalletAdapter, 'publicKey' | 'sendTransaction'>>()
.optional()
})
.strict()

export type PurchaseAlbumRequest = z.input<typeof PurchaseAlbumSchema>
Loading