diff --git a/.vscode/settings.json b/.vscode/settings.json index ac47edc9d98..9f8c605dfb4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,17 +5,13 @@ "reportMissingModuleSource": "none" }, "python.languageServer": "Pylance", - "python.linting.enabled": true, - "python.linting.flake8Enabled": true, - "python.linting.flake8Args": [ + "mypy-type-checker.args": [ "--config", "packages/discovery-provider/setup.cfg" ], - "python.linting.lintOnSave": true, - "python.linting.mypyEnabled": true, + "flake8.args": ["--config", "packages/discovery-provider/setup.cfg"], + "isort.args": ["--settings-path", "packages/discovery-provider/setup.cfg"], - "mypy.configFile": "packages/discovery-provider/setup.cfg", - "mypy.runUsingActiveInterpreter": true, "python.analysis.indexing": true, "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, diff --git a/packages/commands/src/upload-track.mjs b/packages/commands/src/upload-track.mjs index 3b96fa3d3a4..f57834205fe 100644 --- a/packages/commands/src/upload-track.mjs +++ b/packages/commands/src/upload-track.mjs @@ -231,7 +231,7 @@ program genre: genre || Genre[ - Object.keys(Genre)[randomInt(Object.keys(Genre).length - 1)] + Object.keys(Genre)[randomInt(Object.keys(Genre).length - 2) + 1] ], mood: mood || `mood ${rand}`, credits_splits: '', diff --git a/packages/common/src/store/purchase-content/sagas.ts b/packages/common/src/store/purchase-content/sagas.ts index 6e895203f6e..ee4649e0e1d 100644 --- a/packages/common/src/store/purchase-content/sagas.ts +++ b/packages/common/src/store/purchase-content/sagas.ts @@ -1,9 +1,5 @@ import { USDC } from '@audius/fixed-decimal' -import { - type AudiusSdk, - type UsdcGate, - instanceOfPurchaseGate -} from '@audius/sdk' +import { type AudiusSdk } from '@audius/sdk' import BN from 'bn.js' import { sumBy } from 'lodash' import { takeLatest } from 'redux-saga/effects' @@ -233,10 +229,8 @@ const getPurchaseMetadata = (metadata: PurchaseableContentMetadata) => { function* getCoinflowPurchaseMetadata({ contentId, - contentType, - extraAmount, - splits -}: PurchaseWithCoinflowArgs) { + contentType +}: GetPurchaseMetadataArgs) { const { metadata, artistInfo, title, price } = yield* call(getContentInfo, { contentId, contentType @@ -249,8 +243,6 @@ function* getCoinflowPurchaseMetadata({ quantity: 1, rawProductData: { priceUSD: price / 100, - extraAmountUSD: extraAmount ? extraAmount / 100 : 0, - usdcRecipientSplits: splits, artistInfo: getUserPurchaseMetadata(artistInfo), purchaserInfo: currentUser ? getUserPurchaseMetadata(currentUser) : null, contentInfo: getPurchaseMetadata(metadata as PurchaseableContentMetadata) @@ -342,7 +334,12 @@ function* pollForPurchaseConfirmation({ } } -type PurchaseWithCoinflowArgs = { +type GetPurchaseMetadataArgs = { + contentId: ID + contentType: PurchaseableContentType +} + +type PurchaseWithCoinflowOldArgs = { blocknumber: number extraAmount?: number splits: Record @@ -357,7 +354,7 @@ type PurchaseWithCoinflowArgs = { /** * @deprecated Use purchaseTrackWithCoinflow if applicable */ -function* purchaseWithCoinflowOld(args: PurchaseWithCoinflowArgs) { +function* purchaseWithCoinflowOld(args: PurchaseWithCoinflowOldArgs) { const { blocknumber, extraAmount, @@ -436,151 +433,30 @@ async function* purchaseTrackWithCoinflow(args: { price: number extraAmount?: number }) { - const contentType = 'track' - const mint = 'USDC' + const { sdk, userId, trackId, price, extraAmount = 0 } = args + + const audiusBackendInstance = yield* getContext('audiusBackendInstance') + const wallet = yield* call(getRootSolanaAccount, audiusBackendInstance) - const { - sdk, - userId, - trackId, - price: priceNumber, - extraAmount: extraAmountNumber = 0 - } = args const params = { ...args, trackId: encodeHashId(trackId), - userId: encodeHashId(userId) - } - - // In theory, we could have the caller pass in the cached track with all the - // proper information, but we want an up-to-date track so we should fetch - // an up-to-date track - // TODO: Use a method that gets the track into the cache - const { data: track } = yield* call([sdk.tracks, sdk.tracks.getTrack], { - trackId: encodeHashId(trackId) - }) - - // Validate purchase attempt - if (!track) { - throw new Error('Track not found.') - } - - if (!track.isStreamGated && !track.isDownloadGated) { - throw new Error('Attempted to purchase free track.') - } - - if (track.user.id === params.userId) { - throw new Error('Attempted to purchase own track.') - } - - let numberSplits: UsdcGate['splits'] = {} - let centPrice: number - let accessType: 'stream' | 'download' = 'stream' - - // Get conditions - if ( - track.streamConditions && - instanceOfPurchaseGate(track.streamConditions) - ) { - centPrice = track.streamConditions.usdcPurchase.price - numberSplits = track.streamConditions.usdcPurchase.splits - } else if ( - track.downloadConditions && - 'usdcPurchase' in track.downloadConditions - ) { - centPrice = track.downloadConditions.usdcPurchase.price - numberSplits = track.downloadConditions.usdcPurchase.splits - accessType = 'download' - } else { - throw new Error('Track is not available for purchase.') + userId: encodeHashId(userId), + wallet: wallet.publicKey } - - // Check if already purchased - if ( - (accessType === 'download' && track.access?.download) || - (accessType === 'stream' && track.access?.stream) - ) { - throw new Error('Track 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 - console.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 - } - }, - {} - ) - console.debug('Calculated splits after extra amount:', splits) - - // Create user bank for recipient if not exists - console.debug('Checking for recipient user bank...') - const { userBank: recipientUserBank, didExist } = - await sdk.services.claimableTokensClient.getOrCreateUserBank({ - ethWallet: track.user.wallet, - mint: 'USDC' - }) - if (!didExist) { - console.debug('Created user bank', { - recipientUserBank: recipientUserBank.toBase58() - }) - } else { - console.debug('User bank exists', { - recipientUserBank: recipientUserBank.toBase58() - }) - } - - const audiusBackendInstance = yield* getContext('audiusBackendInstance') - const rootAccount = yield* call(getRootSolanaAccount, audiusBackendInstance) - const instructions = yield* call( - sdk.services.paymentRouterClient.createPurchaseContentInstructions, - { - total, - blockNumber: track.blocknumber, - buyerUserId: userId, - sourceWallet: rootAccount.publicKey, - splits, - mint, - contentType, - accessType, - contentId: trackId - } - ) const transaction = yield* call( - [ - sdk.services.paymentRouterClient, - sdk.services.paymentRouterClient.buildTransaction - ], - { - instructions - } + [sdk.tracks, sdk.tracks.getPurchaseTrackTransaction], + params ) const serializedTransaction = Buffer.from(transaction.serialize()).toString( 'base64' ) + const purchaseMetadata = yield* call(getCoinflowPurchaseMetadata, { - blocknumber: track.blocknumber, - purchaserUserId: userId, - purchaseAccess: PurchaseAccess[accessType], contentId: trackId, - contentType: PurchaseableContentType[contentType], - price: Number(USDC(total).toString()), - extraAmount: extraAmountNumber, - splits + contentType: PurchaseableContentType.TRACK }) + const total = (price + extraAmount) / 100 yield* put( coinflowOnrampModalActions.open({ amount: Number(USDC(total).toString()), @@ -620,143 +496,31 @@ async function* purchaseAlbumWithCoinflow(args: { price: number extraAmount?: number }) { - const contentType = 'track' - const mint = 'USDC' + const { sdk, userId, albumId, price, extraAmount = 0 } = args + + const audiusBackendInstance = yield* getContext('audiusBackendInstance') + const wallet = yield* call(getRootSolanaAccount, audiusBackendInstance) - const { - sdk, - userId, - albumId, - price: priceNumber, - extraAmount: extraAmountNumber = 0 - } = args const params = { ...args, - trackId: encodeHashId(albumId), - userId: encodeHashId(userId) - } - - // In theory, we could have the caller pass in the cached album with all the - // proper information, but we want an up-to-date album so we should fetch - // an up-to-date album - // TODO: Use a method that gets the album into the cache - const { data: albums } = yield* call([sdk.albums, sdk.albums.getAlbum], { - albumId: encodeHashId(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 { - throw new Error('Track is not available for purchase.') - } - - // Check if already purchased - if (accessType === 'stream' && album.access?.stream) { - throw new Error('album already purchased') + albumId: encodeHashId(albumId), + userId: encodeHashId(userId), + wallet: wallet.publicKey } - // 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 - console.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 - } - }, - {} - ) - console.debug('Calculated splits after extra amount:', splits) - - // Create user bank for recipient if not exists - console.debug('Checking for recipient user bank...') - const { userBank: recipientUserBank, didExist } = - await sdk.services.claimableTokensClient.getOrCreateUserBank({ - ethWallet: album.user.wallet, - mint: 'USDC' - }) - if (!didExist) { - console.debug('Created user bank', { - recipientUserBank: recipientUserBank.toBase58() - }) - } else { - console.debug('User bank exists', { - recipientUserBank: recipientUserBank.toBase58() - }) - } - - const audiusBackendInstance = yield* getContext('audiusBackendInstance') - const rootAccount = yield* call(getRootSolanaAccount, audiusBackendInstance) - const instructions = yield* call( - sdk.services.paymentRouterClient.createPurchaseContentInstructions, - { - total, - blockNumber: album.blocknumber, - buyerUserId: userId, - sourceWallet: rootAccount.publicKey, - splits, - mint, - contentType, - accessType, - contentId: albumId - } - ) const transaction = yield* call( - [ - sdk.services.paymentRouterClient, - sdk.services.paymentRouterClient.buildTransaction - ], - { - instructions - } + [sdk.tracks, sdk.albums.getPurchaseAlbumTransaction], + params ) const serializedTransaction = Buffer.from(transaction.serialize()).toString( 'base64' ) + const purchaseMetadata = yield* call(getCoinflowPurchaseMetadata, { - blocknumber: album.blocknumber, - purchaserUserId: userId, - purchaseAccess: PurchaseAccess[accessType], contentId: albumId, - contentType: PurchaseableContentType[contentType], - price: Number(USDC(total).toString()), - extraAmount: extraAmountNumber, - splits + contentType: PurchaseableContentType.ALBUM }) + const total = (price + extraAmount) / 100 yield* put( coinflowOnrampModalActions.open({ amount: Number(USDC(total).toString()), @@ -948,14 +712,14 @@ function* doStartPurchaseContentFlow({ // No balance needed, perform the purchase right away if (isUseSDKPurchaseTrackEnabled && contentType === 'track') { - yield* call([sdk.tracks, sdk.tracks.purchase], { + yield* call([sdk.tracks, sdk.tracks.purchaseTrack], { userId: encodeHashId(purchaserUserId), trackId: encodeHashId(contentId), price: price / 100.0, extraAmount: extraAmount ? extraAmount / 100.0 : undefined }) } else if (isUseSDKPurchaseAlbumEnabled && contentType === 'album') { - yield* call([sdk.albums, sdk.albums.purchase], { + yield* call([sdk.albums, sdk.albums.purchaseAlbum], { userId: encodeHashId(purchaserUserId), albumId: encodeHashId(contentId), price: price / 100.0, @@ -1015,7 +779,7 @@ function* doStartPurchaseContentFlow({ // Buy USDC with Stripe. Once funded, continue with purchase. yield* call(purchaseUSDCWithStripe, { amount: purchaseAmount }) if (isUseSDKPurchaseTrackEnabled && contentType === 'track') { - yield* call([sdk.tracks, sdk.tracks.purchase], { + yield* call([sdk.tracks, sdk.tracks.purchaseTrack], { userId: encodeHashId(purchaserUserId), trackId: encodeHashId(contentId), price: price / 100.0, @@ -1025,7 +789,7 @@ function* doStartPurchaseContentFlow({ isUseSDKPurchaseAlbumEnabled && contentType === 'album' ) { - yield* call([sdk.albums, sdk.albums.purchase], { + yield* call([sdk.albums, sdk.albums.purchaseAlbum], { userId: encodeHashId(purchaserUserId), albumId: encodeHashId(contentId), price: price / 100.0, diff --git a/packages/discovery-provider/ddl/migrations/0073_user_usdc_payout_address_history.sql b/packages/discovery-provider/ddl/migrations/0073_user_usdc_payout_address_history.sql new file mode 100644 index 00000000000..24937bf5bce --- /dev/null +++ b/packages/discovery-provider/ddl/migrations/0073_user_usdc_payout_address_history.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS user_payout_wallet_history ( + user_id INTEGER NOT NULL, + spl_usdc_payout_wallet VARCHAR, + blocknumber INTEGER NOT NULL, + block_timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, block_timestamp), + CONSTRAINT user_payout_wallet_history_blocknumber_fkey FOREIGN KEY (blocknumber) REFERENCES blocks("number") ON DELETE CASCADE +); \ No newline at end of file diff --git a/packages/discovery-provider/ddl/migrations/0074_usdc_purchase_splits_user_ids.sql b/packages/discovery-provider/ddl/migrations/0074_usdc_purchase_splits_user_ids.sql new file mode 100644 index 00000000000..cc9df7d7114 --- /dev/null +++ b/packages/discovery-provider/ddl/migrations/0074_usdc_purchase_splits_user_ids.sql @@ -0,0 +1,175 @@ +BEGIN TRANSACTION; + +-- Update track stream price history +WITH updated_track_stream_splits AS ( + SELECT + track_id, + stream_conditions -> 'usdc_purchase' -> 'price' AS price, + jsonb_build_array( + jsonb_build_object('user_id', owner_id, 'percentage', 100.0000) + ) as splits + FROM + tracks + WHERE + jsonb_typeof(stream_conditions -> 'usdc_purchase' -> 'splits') = 'object' +) +UPDATE + track_price_history +SET + splits = updated_track_stream_splits.splits +FROM + updated_track_stream_splits +WHERE + updated_track_stream_splits.track_id = track_price_history.track_id + AND track_price_history.access = 'stream'; + +-- Update track stream conditions +WITH updated_track_stream_splits AS ( + SELECT + track_id, + stream_conditions -> 'usdc_purchase' -> 'price' AS price, + jsonb_build_array( + jsonb_build_object('user_id', owner_id, 'percentage', 100.0000) + ) as splits + FROM + tracks + WHERE + jsonb_typeof(stream_conditions -> 'usdc_purchase' -> 'splits') = 'object' +) +UPDATE + tracks +SET + stream_conditions = jsonb_build_object( + 'usdc_purchase', + jsonb_build_object( + 'price', + updated_track_stream_splits.price, + 'splits', + updated_track_stream_splits.splits + ) + ) +FROM + updated_track_stream_splits +WHERE + updated_track_stream_splits.track_id = tracks.track_id; + +-- Update track download price history +WITH updated_track_download_splits AS ( + SELECT + track_id, + download_conditions -> 'usdc_purchase' -> 'price' AS price, + jsonb_build_array( + jsonb_build_object('user_id', owner_id, 'percentage', 100.0000) + ) as splits + FROM + tracks + WHERE + jsonb_typeof( + download_conditions -> 'usdc_purchase' -> 'splits' + ) = 'object' +) +UPDATE + track_price_history +SET + splits = updated_track_download_splits.splits +FROM + updated_track_download_splits +WHERE + updated_track_download_splits.track_id = track_price_history.track_id + AND track_price_history.access = 'download'; + +-- Update track download conditions +WITH updated_track_download_splits AS ( + SELECT + track_id, + download_conditions -> 'usdc_purchase' -> 'price' AS price, + jsonb_build_array( + jsonb_build_object('user_id', owner_id, 'percentage', 100.0000) + ) as splits + FROM + tracks + WHERE + jsonb_typeof( + download_conditions -> 'usdc_purchase' -> 'splits' + ) = 'object' +) +UPDATE + tracks +SET + download_conditions = jsonb_build_object( + 'usdc_purchase', + jsonb_build_object( + 'price', + updated_track_download_splits.price, + 'splits', + updated_track_download_splits.splits + ) + ) +FROM + updated_track_download_splits +WHERE + updated_track_download_splits.track_id = tracks.track_id; + +-- Update album price history +WITH updated_album_stream_splits AS ( + SELECT + playlist_id, + stream_conditions -> 'usdc_purchase' -> 'price' AS price, + jsonb_build_array( + jsonb_build_object( + 'user_id', + playlist_owner_id, + 'percentage', + 100.0000 + ) + ) as splits + FROM + playlists + WHERE + jsonb_typeof(stream_conditions -> 'usdc_purchase' -> 'splits') = 'object' +) +UPDATE + album_price_history +SET + splits = updated_album_stream_splits.splits +FROM + updated_album_stream_splits +WHERE + updated_album_stream_splits.playlist_id = album_price_history.playlist_id; + +-- Update album stream conditions +WITH updated_album_stream_splits AS ( + SELECT + playlist_id, + stream_conditions -> 'usdc_purchase' -> 'price' AS price, + jsonb_build_array( + jsonb_build_object( + 'user_id', + playlist_owner_id, + 'percentage', + 100.0000 + ) + ) as splits + FROM + playlists + WHERE + jsonb_typeof(stream_conditions -> 'usdc_purchase' -> 'splits') = 'object' +) +UPDATE + playlists +SET + stream_conditions = jsonb_build_object( + 'usdc_purchase', + jsonb_build_object( + 'price', + updated_album_stream_splits.price, + 'splits', + updated_album_stream_splits.splits + ) + ) +FROM + updated_album_stream_splits +WHERE + updated_album_stream_splits.playlist_id = playlists.playlist_id; + +COMMIT; \ No newline at end of file diff --git a/packages/discovery-provider/integration_tests/queries/test_get_extended_purchase_gate.py b/packages/discovery-provider/integration_tests/queries/test_get_extended_purchase_gate.py new file mode 100644 index 00000000000..a35fd86e700 --- /dev/null +++ b/packages/discovery-provider/integration_tests/queries/test_get_extended_purchase_gate.py @@ -0,0 +1,114 @@ +import datetime + +from integration_tests.utils import populate_mock_db +from src.queries.get_extended_purchase_gate import ( + add_wallet_info_to_splits, + calculate_split_amounts, + get_extended_purchase_gate, + to_wallet_amount_map, +) +from src.utils.db_session import get_db + + +def test_get_extended_splits(app): + with app.app_context(): + db = get_db() + + entities = { + "users": [ + { + "user_id": 1, + "wallet": "0xEthereum-wallet", + "spl_usdc_payout_wallet": "second-wallet", + }, + {"user_id": 2, "wallet": "0xEthereum-wallet-2"}, + ], + "user_payout_wallet_history": [ + { + "user_id": 1, + "spl_usdc_payout_wallet": "first-wallet", + "block_timestamp": datetime.date(2024, 5, 1), + }, + { + "user_id": 1, + "spl_usdc_payout_wallet": "second-wallet", + "block_timestamp": datetime.date(2024, 5, 3), + }, + ], + "usdc_user_bank_accounts": [ + {"ethereum_address": "0xEthereum-wallet", "bank_account": "user-bank"} + ], + "tracks": [ + { + "track_id": 1, + "owner_id": 1, + "is_stream_gated": True, + "stream_conditions": { + "usdc_purchase": { + "price": 100, + "splits": [{"user_id": 1, "percentage": 100}], + } + }, + } + ], + "track_price_history": [ + { + "track_id": 1, + "total_price_cents": 100, + "splits": [{"user_id": 1, "percentage": 100}], + "access": "stream", + "block_timestamp": datetime.date(2024, 5, 1), + } + ], + } + populate_mock_db(db, entities) + with db.scoped_session() as session: + + # Gets most recent payout wallet by default + res = get_extended_purchase_gate( + { + "usdc_purchase": { + "price": 100, + "splits": [{"user_id": 1, "percentage": 100}], + } + }, + session, + ) + assert res is not None + assert res["usdc_purchase"]["splits"][0]["amount"] == 1000000 + assert res["usdc_purchase"]["splits"][0]["payout_wallet"] == "second-wallet" + + # Gets older payout wallet when necessary + splits = add_wallet_info_to_splits( + session, [{"user_id": 1, "percentage": 100}], datetime.date(2024, 5, 2) + ) + assert splits[0]["payout_wallet"] == "first-wallet" + splits = calculate_split_amounts(100, splits) + legacy_splits = to_wallet_amount_map(splits) + assert "first-wallet" in legacy_splits + assert legacy_splits["first-wallet"] == 1000000 + + # Falls back to user bank if present + even_older_splits = add_wallet_info_to_splits( + session, [{"user_id": 1, "percentage": 100}], datetime.date(2024, 4, 1) + ) + assert even_older_splits[0]["payout_wallet"] == "user-bank" + even_older_splits = calculate_split_amounts(100, even_older_splits) + legacy_splits = to_wallet_amount_map(even_older_splits) + assert "user-bank" in legacy_splits + assert legacy_splits["user-bank"] == 1000000 + + # Returns None if no user bank indexed and no payout wallet + other_user_splits = add_wallet_info_to_splits( + session, [{"user_id": 2, "percentage": 100}], datetime.date(2024, 4, 1) + ) + assert other_user_splits[0]["payout_wallet"] is None + other_user_splits = calculate_split_amounts(100, other_user_splits) + other_user_splits = to_wallet_amount_map(other_user_splits) + # Legacy splits is empty dictionary which may break old clients if + # content owner user bank doesn't exist when track is being purchased, + # but those old clients should be making the owner's user bank anyway. + # Ultimately a bug in old clients and they will be forced to update + # soon anyway. + assert isinstance(other_user_splits, dict) + assert not other_user_splits diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py index 41576731896..b9879a693ad 100644 --- a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py +++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py @@ -1408,7 +1408,10 @@ def get_events_side_effect(_, tx_receipt): assert album.is_album == True assert album.is_stream_gated == True assert album.stream_conditions == { - "usdc_purchase": {"price": 100, "splits": {"user-bank": 1000000}} + "usdc_purchase": { + "price": 100, + "splits": [{"user_id": 1, "percentage": 100}], + } } # Validate previously usdc-gated album that is now public diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_track_entity_manager.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_track_entity_manager.py index c9c11c2a097..36758a323fb 100644 --- a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_track_entity_manager.py +++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_track_entity_manager.py @@ -1761,10 +1761,16 @@ def get_events_side_effect(_, tx_receipt): track3 = all_tracks[2] assert track3.is_downloadable == True assert track3.stream_conditions == { - "usdc_purchase": {"price": 200, "splits": {"user-bank": 2000000}} + "usdc_purchase": { + "price": 200, + "splits": [{"user_id": 1, "percentage": 100}], + } } assert track3.download_conditions == { - "usdc_purchase": {"price": 200, "splits": {"user-bank": 2000000}} + "usdc_purchase": { + "price": 200, + "splits": [{"user_id": 1, "percentage": 100}], + } } diff --git a/packages/discovery-provider/integration_tests/utils.py b/packages/discovery-provider/integration_tests/utils.py index 32487538001..97e2c6701de 100644 --- a/packages/discovery-provider/integration_tests/utils.py +++ b/packages/discovery-provider/integration_tests/utils.py @@ -46,6 +46,7 @@ from src.models.users.user_balance_change import UserBalanceChange from src.models.users.user_bank import USDCUserBankAccount, UserBankAccount, UserBankTx from src.models.users.user_listening_history import UserListeningHistory +from src.models.users.user_payout_wallet_history import UserPayoutWalletHistory from src.models.users.user_tip import UserTip from src.tasks.aggregates import get_latest_blocknumber from src.utils import helpers @@ -159,6 +160,7 @@ def populate_mock_db(db, entities, block_offset=None): usdc_transactions_history = entities.get("usdc_transactions_history", []) track_price_history = entities.get("track_price_history", []) album_price_history = entities.get("album_price_history", []) + user_payout_wallet_history = entities.get("user_payout_wallet_history", []) num_blocks = max( len(tracks), @@ -172,6 +174,9 @@ def populate_mock_db(db, entities, block_offset=None): len(reposts), len(subscriptions), len(playlist_seens), + len(track_price_history), + len(album_price_history), + len(user_payout_wallet_history), ) for i in range(block_offset, block_offset + num_blocks): max_block = session.query(Block).filter(Block.number == i).first() @@ -740,5 +745,17 @@ def populate_mock_db(db, entities, block_offset=None): tx_metadata=usdc_transaction.get("tx_metadata"), ) session.add(transaction) + for i, user_payout_wallet in enumerate(user_payout_wallet_history): + user_payout_wallet_history_record = UserPayoutWalletHistory( + user_id=user_payout_wallet.get("user_id", i), + spl_usdc_payout_wallet=user_payout_wallet.get( + "spl_usdc_payout_wallet", None + ), + blocknumber=user_payout_wallet.get("blocknumber", i + block_offset), + block_timestamp=user_payout_wallet.get( + "block_timestamp", datetime.now() + ), + ) + session.add(user_payout_wallet_history_record) session.commit() diff --git a/packages/discovery-provider/src/api/v1/helpers.py b/packages/discovery-provider/src/api/v1/helpers.py index 76828ff0ef9..9ad66a14906 100644 --- a/packages/discovery-provider/src/api/v1/helpers.py +++ b/packages/discovery-provider/src/api/v1/helpers.py @@ -11,6 +11,7 @@ from src.api.v1.models.common import full_response from src.models.rewards.challenge import ChallengeType from src.queries.get_challenges import ChallengeResponse +from src.queries.get_extended_purchase_gate import get_legacy_purchase_gate from src.queries.get_support_for_user import SupportResponse from src.queries.get_undisbursed_challenges import UndisbursedChallengeResponse from src.queries.query_helpers import ( @@ -391,6 +392,16 @@ def extend_track(track): duration += float(segment["duration"]) track["duration"] = round(duration) + # Transform new format of splits to legacy format for client compatibility + if "stream_conditions" in track: + track["stream_conditions"] = get_legacy_purchase_gate( + track["stream_conditions"] + ) + if "download_conditions" in track: + track["download_conditions"] = get_legacy_purchase_gate( + track["download_conditions"] + ) + return track @@ -652,7 +663,7 @@ def __init__( @property def __schema__(self): - if self.doc == False: + if self.doc is False: return None param = super().__schema__ param["description"] = self.description diff --git a/packages/discovery-provider/src/api/v1/models/access_gate.py b/packages/discovery-provider/src/api/v1/models/access_gate.py index 829c7313de1..1bc6678504b 100644 --- a/packages/discovery-provider/src/api/v1/models/access_gate.py +++ b/packages/discovery-provider/src/api/v1/models/access_gate.py @@ -47,6 +47,14 @@ ) ns.add_model("wild_card_split", wild_card_split) +payment_split = ns.model( + "payment_split", + { + "user_id": fields.Integer(required=True), + "percentage": fields.Float(required=True), + }, +) + usdc_gate = ns.model( "usdc_gate", { @@ -77,3 +85,47 @@ ], ), ) + + +extended_payment_split = ns.clone( + "extended_payment_split", + payment_split, + { + "eth_wallet": fields.String(required=True), + "payout_wallet": fields.String(required=True), + "amount": fields.Integer(required=True), + }, +) + + +extended_usdc_gate = ns.model( + "extended_usdc_gate", + { + "price": fields.Integer(required=True), + "splits": fields.List(fields.Nested(extended_payment_split), required=True), + }, +) + +extended_purchase_gate = ns.model( + "extended_purchase_gate", + { + "usdc_purchase": fields.Nested( + extended_usdc_gate, + required=True, + description="Must pay the total price and split to the given addresses to unlock", + ) + }, +) + +extended_access_gate = ns.add_model( + "extended_access_gate", + OneOfModel( + "extended_access_gate", + [ + fields.Nested(tip_gate), + fields.Nested(follow_gate), + fields.Nested(extended_purchase_gate), + fields.Nested(nft_gate), + ], + ), +) diff --git a/packages/discovery-provider/src/api/v1/models/extensions/fields.py b/packages/discovery-provider/src/api/v1/models/extensions/fields.py index a668d55762a..c1f199ddb28 100644 --- a/packages/discovery-provider/src/api/v1/models/extensions/fields.py +++ b/packages/discovery-provider/src/api/v1/models/extensions/fields.py @@ -86,16 +86,18 @@ def output(self, key, data, **kwargs): return None elif self.default is not None: return self.default + logs = [] for field in self.model.fields: try: marshalled = marshal(value, field.nested) if value == marshalled: return value + logs.append(f"marshalled={marshalled}") except fields.MarshallingError as e: logger.error( f"fields.py | NestedOneOf | Failed to marshal key={key} value={value} error={e.msg}" ) logger.error( - f"fields.py | NestedOneOf | Failed to marshal key={key} value={data}: No matching models." + f"fields.py | NestedOneOf | Failed to marshal key={key} value={value}: No matching models. {logs}" ) return value diff --git a/packages/discovery-provider/src/api/v1/models/playlists.py b/packages/discovery-provider/src/api/v1/models/playlists.py index ad14d7955e9..fc630943b6f 100644 --- a/packages/discovery-provider/src/api/v1/models/playlists.py +++ b/packages/discovery-provider/src/api/v1/models/playlists.py @@ -1,10 +1,10 @@ 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 +from .access_gate import access_gate, extended_access_gate from .common import favorite, ns, repost playlist_artwork = ns.model( @@ -32,7 +32,6 @@ playlist_model = ns.model( "playlist", { - "blocknumber": fields.Integer(required=True), "artwork": fields.Nested(playlist_artwork, allow_null=True), "description": fields.String, "permalink": fields.String, @@ -50,12 +49,6 @@ "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", - ), }, ) @@ -63,6 +56,7 @@ "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), @@ -81,6 +75,12 @@ "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": NestedOneOf( + access_gate, + allow_null=True, + description="How to unlock stream access to the track", + ), }, ) @@ -91,3 +91,26 @@ "tracks": fields.List(fields.Nested(track_full), required=True), }, ) + +album_access_info = ns.model( + "album_access_info", + { + "access": fields.Nested( + access, description="Describes what access the given user has" + ), + "user_id": fields.String( + required=True, description="The user ID of the owner of this album" + ), + "blocknumber": fields.Integer( + required=True, description="The blocknumber this album was last updated" + ), + "is_stream_gated": fields.Boolean( + description="Whether or not the owner has restricted streaming behind an access gate" + ), + "stream_conditions": NestedOneOf( + extended_access_gate, + allow_null=True, + description="How to unlock stream access to the track", + ), + }, +) diff --git a/packages/discovery-provider/src/api/v1/models/tracks.py b/packages/discovery-provider/src/api/v1/models/tracks.py index 85e3d707e1e..b9339ef5ff4 100644 --- a/packages/discovery-provider/src/api/v1/models/tracks.py +++ b/packages/discovery-provider/src/api/v1/models/tracks.py @@ -1,6 +1,6 @@ from flask_restx import fields -from .access_gate import access_gate +from .access_gate import access_gate, extended_access_gate from .common import favorite, ns, repost from .extensions.fields import NestedOneOf from .users import user_model, user_model_full @@ -72,13 +72,7 @@ track = ns.model( "Track", { - "access": fields.Nested( - access, description="Describes what access the given user has" - ), "artwork": fields.Nested(track_artwork, allow_null=True), - "blocknumber": fields.Integer( - required=True, description="The blocknumber this track was last updated" - ), "description": fields.String, "genre": fields.String, "id": fields.String(required=True), @@ -109,20 +103,6 @@ "is_streamable": fields.Boolean, "ddex_app": fields.String(allow_null=True), "playlists_containing_track": fields.List(fields.Integer), - "is_stream_gated": fields.Boolean( - description="Whether or not the owner has restricted streaming behind an access gate" - ), - "stream_conditions": NestedOneOf( - access_gate, - allow_null=True, - description="How to unlock stream access to the track", - ), - "is_download_gated": fields.Boolean( - description="Whether or not the owner has restricted downloading behind an access gate" - ), - "download_conditions": NestedOneOf( - access_gate, allow_null=True, description="How to unlock the track download" - ), }, ) @@ -153,6 +133,12 @@ "track_full", track, { + "access": fields.Nested( + access, description="Describes what access the given user has" + ), + "blocknumber": fields.Integer( + required=True, description="The blocknumber this track was last updated" + ), "create_date": fields.String, "cover_art_sizes": fields.String, "cover_art_cids": fields.Nested(cover_art, allow_null=True), @@ -190,6 +176,20 @@ "copyright_line": fields.Raw(allow_null=True), "producer_copyright_line": fields.Raw(allow_null=True), "parental_warning_type": fields.String, + "is_stream_gated": fields.Boolean( + description="Whether or not the owner has restricted streaming behind an access gate" + ), + "stream_conditions": NestedOneOf( + access_gate, + allow_null=True, + description="How to unlock stream access to the track", + ), + "is_download_gated": fields.Boolean( + description="Whether or not the owner has restricted downloading behind an access gate" + ), + "download_conditions": NestedOneOf( + access_gate, allow_null=True, description="How to unlock the track download" + ), }, ) @@ -213,3 +213,35 @@ "tracks": fields.List(fields.Nested(track_full)), }, ) + + +track_access_info = ns.model( + "track_access_info", + { + "access": fields.Nested( + access, description="Describes what access the given user has" + ), + "user_id": fields.String( + required=True, description="The user ID of the owner of this track" + ), + "blocknumber": fields.Integer( + required=True, description="The blocknumber this track was last updated" + ), + "is_stream_gated": fields.Boolean( + description="Whether or not the owner has restricted streaming behind an access gate" + ), + "stream_conditions": NestedOneOf( + extended_access_gate, + allow_null=True, + description="How to unlock stream access to the track", + ), + "is_download_gated": fields.Boolean( + description="Whether or not the owner has restricted downloading behind an access gate" + ), + "download_conditions": NestedOneOf( + extended_access_gate, + allow_null=True, + description="How to unlock the track download", + ), + }, +) diff --git a/packages/discovery-provider/src/api/v1/playlists.py b/packages/discovery-provider/src/api/v1/playlists.py index 1540a180369..78155a224ce 100644 --- a/packages/discovery-provider/src/api/v1/playlists.py +++ b/packages/discovery-provider/src/api/v1/playlists.py @@ -7,6 +7,7 @@ from src.api.v1.helpers import ( abort_bad_path_param, abort_bad_request_param, + abort_not_found, current_user_parser, decode_with_abort, extend_playlist, @@ -22,8 +23,13 @@ success_response, trending_parser, ) -from src.api.v1.models.playlists import full_playlist_model, playlist_model +from src.api.v1.models.playlists import ( + album_access_info, + full_playlist_model, + playlist_model, +) from src.api.v1.models.users import user_model_full +from src.queries.get_extended_purchase_gate import get_extended_purchase_gate from src.queries.get_playlist_tracks import get_playlist_tracks from src.queries.get_playlists import get_playlists from src.queries.get_reposters_for_playlist import get_reposters_for_playlist @@ -561,3 +567,35 @@ class GetUnclaimedPlaylistId(Resource): def get(self): unclaimed_id = get_unclaimed_id("playlist") return success_response(unclaimed_id) + + +access_info_response = make_response( + "access_info_response", ns, fields.Nested(album_access_info) +) + + +@ns.route("//access-info") +class GetPlaylistAccessInfo(Resource): + @record_metrics + @ns.doc( + id="Get Playlist Access Info", + description="Gets the information necessary to access the playlist and what access the given user has.", + params={"playlist_id": "A Playlist ID"}, + ) + @ns.expect(current_user_parser) + @ns.marshal_with(access_info_response) + def get(self, playlist_id: str): + args = current_user_parser.parse_args() + decoded_id = decode_with_abort(playlist_id, full_ns) + current_user_id = get_current_user_id(args) + playlist = get_playlist(playlist_id=decoded_id, current_user_id=current_user_id) + if not playlist: + abort_not_found(playlist_id, ns) + playlist = extend_track(playlist[0]) + playlist["stream_conditions"] = get_extended_purchase_gate( + playlist["stream_conditions"] + ) + playlist["download_conditions"] = get_extended_purchase_gate( + playlist["download_conditions"] + ) + return success_response(playlist) diff --git a/packages/discovery-provider/src/api/v1/tracks.py b/packages/discovery-provider/src/api/v1/tracks.py index acd65efaf8d..9dc4c636d71 100644 --- a/packages/discovery-provider/src/api/v1/tracks.py +++ b/packages/discovery-provider/src/api/v1/tracks.py @@ -42,6 +42,7 @@ TRENDING_TRACKS_LIMIT, TRENDING_TRACKS_TTL_SEC, ) +from src.queries.get_extended_purchase_gate import get_extended_purchase_gate from src.queries.get_feed import get_feed from src.queries.get_latest_entities import get_latest_entities from src.queries.get_nft_gated_track_signatures import get_nft_gated_track_signatures @@ -67,7 +68,7 @@ get_track_download_signature, get_track_stream_signature, ) -from src.queries.get_tracks import RouteArgs, get_tracks +from src.queries.get_tracks import GetTrackArgs, RouteArgs, get_tracks from src.queries.get_trending import get_trending from src.queries.get_trending_ids import get_trending_ids from src.queries.get_unclaimed_id import get_unclaimed_id @@ -86,7 +87,7 @@ from .models.tracks import blob_info from .models.tracks import remixes_response as remixes_response_model -from .models.tracks import stem_full, track, track_full +from .models.tracks import stem_full, track, track_access_info, track_full logger = logging.getLogger(__name__) @@ -1744,3 +1745,41 @@ class GetUnclaimedTrackId(Resource): def get(self): unclaimed_id = get_unclaimed_id("track") return success_response(unclaimed_id) + + +access_info_response = make_response( + "access_info_response", ns, fields.Nested(track_access_info) +) + + +@ns.route("//access-info") +class GetTrackAccessInfo(Resource): + @record_metrics + @ns.doc( + id="Get Track Access Info", + description="Gets the information necessary to access the track and what access the given user has.", + params={"track_id": "A Track ID"}, + ) + @ns.expect(current_user_parser) + @ns.marshal_with(access_info_response) + def get(self, track_id: str): + args = current_user_parser.parse_args() + decoded_id = decode_with_abort(track_id, full_ns) + current_user_id = get_current_user_id(args) + get_track_args: GetTrackArgs = { + "id": [decoded_id], + "filter_deleted": True, + "exclude_gated": False, + "skip_unlisted_filter": True, + "current_user_id": current_user_id, + } + tracks = get_tracks(get_track_args) + if not tracks: + abort_not_found(track_id, ns) + raw = tracks[0] + stream_conditions = get_extended_purchase_gate(raw["stream_conditions"]) + download_conditions = get_extended_purchase_gate(raw["download_conditions"]) + track = extend_track(raw) + track["stream_conditions"] = stream_conditions + track["download_conditions"] = download_conditions + return success_response(track) diff --git a/packages/discovery-provider/src/models/tracks/track_price_history.py b/packages/discovery-provider/src/models/tracks/track_price_history.py index 72f5a56a0c3..c7797dc7b97 100644 --- a/packages/discovery-provider/src/models/tracks/track_price_history.py +++ b/packages/discovery-provider/src/models/tracks/track_price_history.py @@ -1,7 +1,6 @@ -from typing import Self - from sqlalchemy import BigInteger, Column, DateTime, Enum, Integer, text from sqlalchemy.dialects.postgresql import JSONB +from typing_extensions import Self from src.models.base import Base from src.models.model_utils import RepresentableMixin diff --git a/packages/discovery-provider/src/models/users/user_payout_wallet_history.py b/packages/discovery-provider/src/models/users/user_payout_wallet_history.py new file mode 100644 index 00000000000..e59c4d50762 --- /dev/null +++ b/packages/discovery-provider/src/models/users/user_payout_wallet_history.py @@ -0,0 +1,20 @@ +from sqlalchemy import BigInteger, Column, DateTime, Integer, String, text +from typing_extensions import Self + +from src.models.base import Base +from src.models.model_utils import RepresentableMixin + + +class UserPayoutWalletHistory(Base, RepresentableMixin): + __tablename__ = "user_payout_wallet_history" + + user_id = Column(Integer, nullable=False, primary_key=True) + spl_usdc_payout_wallet = Column(String, nullable=True) + blocknumber = Column(BigInteger, nullable=False) + block_timestamp = Column(DateTime, nullable=False, primary_key=True) + created_at = Column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP") + ) + + def equals(self, rec: Self): + return self.spl_usdc_payout_wallet == rec.spl_usdc_payout_wallet diff --git a/packages/discovery-provider/src/queries/get_extended_purchase_gate.py b/packages/discovery-provider/src/queries/get_extended_purchase_gate.py new file mode 100644 index 00000000000..6ede097c269 --- /dev/null +++ b/packages/discovery-provider/src/queries/get_extended_purchase_gate.py @@ -0,0 +1,240 @@ +import logging +from datetime import datetime +from typing import Dict, List, Optional, TypedDict, Union, cast + +from sqlalchemy import and_, func +from sqlalchemy.orm import aliased +from sqlalchemy.orm.session import Session + +from src.models.playlists.playlist import Playlist +from src.models.tracks.track import Track +from src.models.users.user import User +from src.models.users.user_bank import USDCUserBankAccount +from src.models.users.user_payout_wallet_history import UserPayoutWalletHistory +from src.utils.db_session import get_db_read_replica +from src.utils.session_manager import SessionManager + +logger = logging.getLogger(__name__) + + +class GetPurchaseInfoArgs(TypedDict): + content_id: int + content_type: str + content: Union[Track, Playlist] + + +class Split(TypedDict): + user_id: int + percentage: float + + +class USDCPurchaseCondition(TypedDict): + price: int + splits: List[Split] + + +class PurchaseGate(TypedDict): + usdc_purchase: USDCPurchaseCondition + + +class ExtendedSplit(Split): + amount: int + payout_wallet: str + eth_wallet: str + + +class ExtendedUSDCPurchaseCondition(TypedDict): + price: int + splits: List[ExtendedSplit] + + +class ExtendedPurchaseGate(TypedDict): + usdc_purchase: ExtendedUSDCPurchaseCondition + + +class LegacyUSDCPurchaseCondition(TypedDict): + price: int + splits: Dict[str, int] + + +class LegacyPurchaseGate(TypedDict): + usdc_purchase: LegacyUSDCPurchaseCondition + + +class FollowGate(TypedDict): + follow_user_id: int + + +class TipGate(TypedDict): + tip_user_id: int + + +class NFTCollection(TypedDict): + chain: str + address: str + name: str + imageUrl: Optional[str] + externalLink: Optional[str] + + +class NFTGate(TypedDict): + nft_collection: NFTCollection + + +AccessGate = Union[PurchaseGate, FollowGate, TipGate, NFTGate] + + +# Allow up to 6 decimal points for split percentages +percentage_decimals = 6 +percentage_multiplier = 10**percentage_decimals + + +cents_to_usdc_multiplier = 10**4 + + +def calculate_split_amounts(price: int, splits: List[Split]): + """ + Deterministically calculates the USDC amounts to pay to each person, + adjusting for rounding errors and ensuring the total matches the price. + """ + price_in_usdc = int(cents_to_usdc_multiplier * price) + running_total = 0 + new_splits: List[Dict] = [] + for index in range(len(splits)): + split = splits[index] + # multiply percentage to make it a whole number + percentage_whole = int(split["percentage"] * percentage_multiplier) + # do safe integer math on the price + amount = percentage_whole * price_in_usdc + # divide by the percentage multiplier afterward, and convert percent + amount = amount / (percentage_multiplier * 100) + # round towards zero, it'll round up later as necessary + amount_in_usdc = int(amount) + new_split: Dict = cast(dict, split) + new_split["amount"] = amount_in_usdc + # save the fractional component and index for rounding/sorting later + new_split["_amount_fractional"] = amount - amount_in_usdc + new_split["_index"] = index + new_splits.append(new_split) + running_total += amount_in_usdc + # Resolve rounding errors by iteratively choosing the highest fractional + # rounding errors to round up until the running total is correct + new_splits.sort(key=lambda item: (-item["_amount_fractional"], item["amount"])) + index = 0 + while running_total < price_in_usdc: + new_splits[index]["amount"] += 1 + running_total += 1 + index = (index + 1) % len(new_splits) + if running_total != price_in_usdc: + raise Exception( + f"Bad splits math: Expected {price_in_usdc} but got {running_total}. new_splits={new_splits}" + ) + # sort back to original order + new_splits.sort(key=lambda item: item["_index"]) + for s in new_splits: + del s["_index"] + del s["_amount_fractional"] + return new_splits + + +def add_wallet_info_to_splits( + session: Session, splits: List[Split], timestamp: Optional[datetime] +): + user_ids = [split["user_id"] for split in splits] + + max_block_timestamps = ( + session.query( + UserPayoutWalletHistory.user_id, + func.max(UserPayoutWalletHistory.block_timestamp).label("block_timestamp"), + ) + .filter(UserPayoutWalletHistory.block_timestamp < timestamp) + .filter(UserPayoutWalletHistory.user_id.in_(user_ids)) + .group_by(UserPayoutWalletHistory.user_id) + .cte("max_block_timestamps") + ) + RelevantTimestamps = aliased(max_block_timestamps) + + wallets_query = ( + session.query( + User.user_id, + User.wallet, + UserPayoutWalletHistory.spl_usdc_payout_wallet, + USDCUserBankAccount.bank_account, + ) + .outerjoin( + USDCUserBankAccount, USDCUserBankAccount.ethereum_address == User.wallet + ) + .outerjoin(RelevantTimestamps, RelevantTimestamps.c.user_id == User.user_id) + .outerjoin( + UserPayoutWalletHistory, + and_( + UserPayoutWalletHistory.user_id == RelevantTimestamps.c.user_id, + UserPayoutWalletHistory.block_timestamp + == RelevantTimestamps.c.block_timestamp, + ), + ) + .filter(User.user_id.in_(user_ids)) + ) + wallets = wallets_query.all() + new_splits: List[dict] = [] + for split in splits: + user_id = split["user_id"] + (user_id, eth_wallet, payout_wallet, usdc_bank_account) = next( + (p for p in wallets if p[0] == user_id), + (user_id, None, None, None), + ) + new_split: Dict = cast(dict, split) + new_split["eth_wallet"] = eth_wallet + new_split["payout_wallet"] = ( + payout_wallet if payout_wallet else usdc_bank_account + ) + new_splits.append(new_split) + return splits + + +def to_wallet_amount_map(splits: List[ExtendedSplit]): + return { + split["payout_wallet"]: split["amount"] + for split in splits + if split["payout_wallet"] is not None + } + + +def _get_extended_purchase_gate(session: Session, gate: PurchaseGate): + price = gate["usdc_purchase"]["price"] + splits = gate["usdc_purchase"]["splits"] + splits = add_wallet_info_to_splits(session, splits, datetime.now()) + splits = calculate_split_amounts(price, splits) + extended_splits = [cast(ExtendedSplit, split) for split in splits] + extended_gate: ExtendedPurchaseGate = { + "usdc_purchase": {"price": price, "splits": extended_splits} + } + return extended_gate + + +def get_extended_purchase_gate(gate: AccessGate, session=None): + if gate and "usdc_purchase" in gate: + # mypy gets confused.... + gate = cast(PurchaseGate, gate) + if session: + return _get_extended_purchase_gate(session, gate) + else: + db: SessionManager = get_db_read_replica() + with db.scoped_session() as session: + return _get_extended_purchase_gate(session, gate) + + +def get_legacy_purchase_gate(gate: AccessGate, session=None): + if gate and "usdc_purchase" in gate: + # mypy gets confused.... + gate = cast(PurchaseGate, gate) + if session: + new_gate = _get_extended_purchase_gate(session, gate) + else: + db: SessionManager = get_db_read_replica() + with db.scoped_session() as session: + new_gate = _get_extended_purchase_gate(session, gate) + extended_splits = new_gate["usdc_purchase"]["splits"] + splits = to_wallet_amount_map(extended_splits) + new_gate["usdc_purchase"]["splits"] = splits + return new_gate diff --git a/packages/discovery-provider/src/queries/get_extended_purchase_gate_unit_test.py b/packages/discovery-provider/src/queries/get_extended_purchase_gate_unit_test.py new file mode 100644 index 00000000000..cf0029db435 --- /dev/null +++ b/packages/discovery-provider/src/queries/get_extended_purchase_gate_unit_test.py @@ -0,0 +1,79 @@ +import logging +from typing import List + +from src.queries.get_extended_purchase_gate import Split, calculate_split_amounts + +logger = logging.getLogger(__name__) + + +def test_calculate_split_amounts_validate(caplog): + caplog.set_level(logging.DEBUG) + price = 100 + og_splits: List[Split] = [ + {"user_id": 1, "percentage": 0.00010}, + {"user_id": 2, "percentage": 1.00000}, + {"user_id": 3, "percentage": 10.00000}, + {"user_id": 4, "percentage": 3.33333}, + {"user_id": 5, "percentage": 3.33333}, + {"user_id": 6, "percentage": 3.33333}, + {"user_id": 7, "percentage": 25.0000}, + {"user_id": 8, "percentage": 50.0000}, + {"user_id": 9, "percentage": 4.00000}, + ] + res_splits = calculate_split_amounts(price, og_splits) + split_map_by_user = {split["user_id"]: split for split in res_splits} + assert split_map_by_user[1]["amount"] == 1 + assert split_map_by_user[2]["amount"] == 10000 + assert split_map_by_user[3]["amount"] == 100000 + assert split_map_by_user[4]["amount"] == 33333 + assert split_map_by_user[5]["amount"] == 33333 + assert split_map_by_user[6]["amount"] == 33333 + assert split_map_by_user[7]["amount"] == 250000 + assert split_map_by_user[8]["amount"] == 500000 + assert split_map_by_user[9]["amount"] == 40000 + + price = 197 + og_splits: List[Split] = [ + {"user_id": 1, "percentage": 0.00010}, + {"user_id": 2, "percentage": 1.00000}, + {"user_id": 3, "percentage": 10.00000}, + {"user_id": 4, "percentage": 3.333333}, + {"user_id": 5, "percentage": 3.333333}, + {"user_id": 6, "percentage": 3.333333}, + {"user_id": 7, "percentage": 25.00000}, + {"user_id": 8, "percentage": 50.00000}, + {"user_id": 9, "percentage": 4.00000}, + ] + res_splits = calculate_split_amounts(price, og_splits) + split_map_by_user = {split["user_id"]: split for split in res_splits} + assert split_map_by_user[1]["amount"] == 2 + assert split_map_by_user[2]["amount"] == 19700 + assert split_map_by_user[3]["amount"] == 197000 + assert split_map_by_user[4]["amount"] == 65666 + assert split_map_by_user[5]["amount"] == 65666 + assert split_map_by_user[6]["amount"] == 65666 + assert split_map_by_user[7]["amount"] == 492500 + assert split_map_by_user[8]["amount"] == 985000 + assert split_map_by_user[9]["amount"] == 78800 + + price = 100 + og_splits: List[Split] = [ + {"user_id": 1, "percentage": 33.3333}, + {"user_id": 2, "percentage": 66.6667}, + ] + res_splits = calculate_split_amounts(price, og_splits) + split_map_by_user = {split["user_id"]: split for split in res_splits} + assert split_map_by_user[1]["amount"] == 333333 + assert split_map_by_user[2]["amount"] == 666667 + + price = 202 + og_splits: List[Split] = [ + {"user_id": 1, "percentage": 33.33334}, + {"user_id": 2, "percentage": 33.33333}, + {"user_id": 3, "percentage": 33.33333}, + ] + res_splits = calculate_split_amounts(price, og_splits) + split_map_by_user = {split["user_id"]: split for split in res_splits} + assert split_map_by_user[1]["amount"] == 673334 + assert split_map_by_user[2]["amount"] == 673333 + assert split_map_by_user[3]["amount"] == 673333 diff --git a/packages/discovery-provider/src/queries/get_tracks.py b/packages/discovery-provider/src/queries/get_tracks.py index 44e02349514..2ece85f0d2a 100644 --- a/packages/discovery-provider/src/queries/get_tracks.py +++ b/packages/discovery-provider/src/queries/get_tracks.py @@ -31,7 +31,7 @@ class RouteArgs(TypedDict): slug: str -class GetTrackArgs(TypedDict): +class GetTrackArgs(TypedDict, total=False): user_id: int limit: int offset: int diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py b/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py index bbd77d535ab..ce765faff5a 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py @@ -20,6 +20,7 @@ Action, EntityType, ManageEntityParameters, + convert_legacy_purchase_access_gate, copy_record, is_ddex_signer, parse_release_date, @@ -232,7 +233,11 @@ def update_album_price_history( "is_stream_gated", False ) and playlist_metadata.get("stream_conditions", None) if is_stream_gated: - conditions = playlist_metadata["stream_conditions"] + # Convert legacy conditions to new array format with user IDs if necessary + conditions = convert_legacy_purchase_access_gate( + playlist_record.playlist_owner_id, + playlist_metadata["stream_conditions"], + ) if USDC_PURCHASE_KEY in conditions: usdc_purchase = conditions[USDC_PURCHASE_KEY] new_record = AlbumPriceHistory() @@ -473,6 +478,11 @@ def create_playlist(params: ManageEntityParameters): ) last_added_to = params.block_datetime + # Convert stream conditions from legacy format to new format + stream_conditions = convert_legacy_purchase_access_gate( + params.user_id, params.metadata.get("stream_conditions", None) + ) + playlist_record = Playlist( playlist_id=playlist_id, metadata_multihash=params.metadata_cid, @@ -487,7 +497,7 @@ def create_playlist(params: ManageEntityParameters): is_private=params.metadata.get("is_private", False), is_image_autogenerated=params.metadata.get("is_image_autogenerated", False), is_stream_gated=params.metadata.get("is_stream_gated", False), - stream_conditions=params.metadata.get("stream_conditions", None), + stream_conditions=stream_conditions, playlist_contents={"track_ids": tracks_with_index_time}, created_at=created_at, updated_at=params.block_datetime, @@ -684,7 +694,13 @@ def process_playlist_data_event( if key in immutable_playlist_fields and params.action == Action.UPDATE: # skip fields that cannot be modified after creation continue - setattr(playlist_record, key, playlist_metadata[key]) + elif key == "stream_conditions": + # Convert stream conditions from legacy format to new format + playlist_record.stream_conditions = convert_legacy_purchase_access_gate( + params.user_id, playlist_metadata[key] + ) + else: + setattr(playlist_record, key, playlist_metadata[key]) playlist_record.last_added_to = None track_ids = playlist_record.playlist_contents["track_ids"] if track_ids: diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/track.py b/packages/discovery-provider/src/tasks/entity_manager/entities/track.py index 29c859208a2..bd6625c0c38 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/track.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/track.py @@ -26,6 +26,7 @@ Action, EntityType, ManageEntityParameters, + convert_legacy_purchase_access_gate, copy_record, is_ddex_signer, parse_release_date, @@ -101,13 +102,17 @@ def update_track_price_history( if is_stream_gated else track_metadata["download_conditions"] ) + # Convert legacy conditions to new array format with user IDs + conditions = convert_legacy_purchase_access_gate( + track_record.owner_id, conditions + ) if USDC_PURCHASE_KEY in conditions: usdc_purchase = conditions[USDC_PURCHASE_KEY] new_record = TrackPriceHistory() new_record.track_id = track_record.track_id new_record.block_timestamp = timestamp new_record.blocknumber = blocknumber - new_record.splits = {} + new_record.splits = [] new_record.access = ( PurchaseAccessType.stream if is_stream_gated @@ -129,7 +134,7 @@ def update_track_price_history( if "splits" in usdc_purchase: splits = usdc_purchase["splits"] # TODO: [PAY-2553] better validation of splits - if isinstance(splits, dict): + if isinstance(splits, list): new_record.splits = splits else: raise IndexingValidationError( @@ -240,14 +245,20 @@ def populate_track_record_metadata(track_record: Track, track_metadata, handle, is_valid_json_field(track_metadata, "stream_conditions") or track_metadata["stream_conditions"] is None ): - track_record.stream_conditions = track_metadata["stream_conditions"] + # Convert legacy conditions to new array format with user IDs + track_record.stream_conditions = convert_legacy_purchase_access_gate( + track_record.owner_id, track_metadata["stream_conditions"] + ) elif key == "download_conditions": if "download_conditions" in track_metadata and ( is_valid_json_field(track_metadata, "download_conditions") or track_metadata["download_conditions"] is None ): - track_record.download_conditions = track_metadata["download_conditions"] + # Convert legacy conditions to new array format with user IDs + track_record.download_conditions = convert_legacy_purchase_access_gate( + track_record.owner_id, track_metadata["download_conditions"] + ) elif key == "allowed_api_keys": if key in track_metadata: if track_metadata[key] is None: diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/user.py b/packages/discovery-provider/src/tasks/entity_manager/entities/user.py index b51c462542d..54551be9055 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/user.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/user.py @@ -8,6 +8,7 @@ from nacl.encoding import HexEncoder from nacl.signing import VerifyKey from solders.pubkey import Pubkey +from sqlalchemy import desc from sqlalchemy.orm.session import Session from src.challenges.challenge_event import ChallengeEvent @@ -18,6 +19,7 @@ from src.models.users.associated_wallet import AssociatedWallet from src.models.users.user import User from src.models.users.user_events import UserEvent +from src.models.users.user_payout_wallet_history import UserPayoutWalletHistory from src.queries.get_balances import enqueue_immediate_balance_refresh from src.solana.solana_client_manager import SolanaClientManager from src.solana.solana_helpers import SPL_TOKEN_ID @@ -296,7 +298,30 @@ def update_user( return user_record -def update_user_metadata(user_record: User, metadata: Dict, params): +def update_user_payout_wallet_history( + session: Session, wallet: str, params: ManageEntityParameters +): + new_record = UserPayoutWalletHistory() + new_record.user_id = params.user_id + new_record.spl_usdc_payout_wallet = wallet + new_record.block_timestamp = params.block_datetime + new_record.blocknumber = params.block_number + old_record = ( + session.query(UserPayoutWalletHistory) + .filter(UserPayoutWalletHistory.user_id == params.user_id) + .order_by(desc(UserPayoutWalletHistory.block_timestamp)) + .first() + ) + if not old_record or ( + old_record.block_timestamp != new_record.block_timestamp + and not old_record.equals(new_record) + ): + session.add(new_record) + + +def update_user_metadata( + user_record: User, metadata: Dict, params: ManageEntityParameters +): session = params.session redis = params.redis web3 = params.web3 @@ -345,6 +370,11 @@ def update_user_metadata(user_record: User, metadata: Dict, params): if "events" in metadata and metadata["events"]: update_user_events(user_record, metadata["events"], challenge_event_bus, params) + if "spl_usdc_payout_wallet" in metadata: + update_user_payout_wallet_history( + session, metadata["spl_usdc_payout_wallet"], params + ) + return user_record diff --git a/packages/discovery-provider/src/tasks/entity_manager/utils.py b/packages/discovery-provider/src/tasks/entity_manager/utils.py index 86d703f6ed5..043aca107de 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/utils.py +++ b/packages/discovery-provider/src/tasks/entity_manager/utils.py @@ -542,3 +542,15 @@ def parse_release_date(release_date_str): pass return None + + +def convert_legacy_purchase_access_gate(owner_id: int, access_gate: dict): + """Converts the legacy splits in a purchase gate to the new array format""" + if access_gate and "usdc_purchase" in access_gate: + # Legacy purchase gates have a split dictionary instead of array + if isinstance(access_gate["usdc_purchase"]["splits"], dict): + # Legacy client uploads only have one split, and it's to the owner + access_gate["usdc_purchase"]["splits"] = [ + {"user_id": owner_id, "percentage": 100.0} + ] + return access_gate diff --git a/packages/discovery-provider/src/tasks/index_payment_router.py b/packages/discovery-provider/src/tasks/index_payment_router.py index 78b8b84b35e..6b256b13b87 100644 --- a/packages/discovery-provider/src/tasks/index_payment_router.py +++ b/packages/discovery-provider/src/tasks/index_payment_router.py @@ -36,6 +36,11 @@ ) from src.models.users.user import User from src.models.users.user_bank import USDCUserBankAccount +from src.queries.get_extended_purchase_gate import ( + add_wallet_info_to_splits, + calculate_split_amounts, + to_wallet_amount_map, +) from src.solana.constants import ( FETCH_TX_SIGNATURES_BATCH_SIZE, TX_SIGNATURES_MAX_BATCHES, @@ -348,6 +353,18 @@ def parse_route_transaction_memos( splits = result.splits else: logger.error(f"index_payment_router.py | Unknown content type {type}") + + # Convert the new splits format to the old splits format for + # maximal backwards compatibility + if ( + price is not None + and splits is not None + and isinstance(splits, list) + and content_owner_id is not None + ): + wallet_splits = add_wallet_info_to_splits(session, splits, timestamp) + amount_splits = calculate_split_amounts(price, wallet_splits) + splits = to_wallet_amount_map(amount_splits) if ( price is not None and splits is not None @@ -369,7 +386,7 @@ def parse_route_transaction_memos( continue else: logger.error( - f"index_payment_router.py | Couldn't find relevant price for {content_metadata}" + f"index_payment_router.py | Couldn't find relevant price for {content_metadata}." ) except (ValueError, KeyError) as e: logger.info( diff --git a/packages/discovery-provider/src/tasks/index_user_bank.py b/packages/discovery-provider/src/tasks/index_user_bank.py index 8ec24cfe1fc..b556e3270a7 100644 --- a/packages/discovery-provider/src/tasks/index_user_bank.py +++ b/packages/discovery-provider/src/tasks/index_user_bank.py @@ -40,6 +40,11 @@ from src.models.users.user_bank import USDCUserBankAccount, UserBankAccount, UserBankTx from src.models.users.user_tip import UserTip from src.queries.get_balances import enqueue_immediate_balance_refresh +from src.queries.get_extended_purchase_gate import ( + add_wallet_info_to_splits, + calculate_split_amounts, + to_wallet_amount_map, +) from src.solana.constants import ( FETCH_TX_SIGNATURES_BATCH_SIZE, TX_SIGNATURES_MAX_BATCHES, @@ -376,6 +381,12 @@ def get_purchase_metadata_from_memo( logger.error(f"index_user_bank.py | Unknown content type {type}") continue + # Convert the new splits format to the old splits format for + # maximal backwards compatibility + if price is not None and splits is not None and isinstance(splits, list): + wallet_splits = add_wallet_info_to_splits(session, splits, timestamp) + amount_splits = calculate_split_amounts(price, wallet_splits) + splits = to_wallet_amount_map(amount_splits) if price is not None and splits is not None and isinstance(splits, dict): purchase_metadata: PurchaseMetadataDict = { "type": type, diff --git a/packages/libs/src/sdk/api/albums/AlbumsApi.ts b/packages/libs/src/sdk/api/albums/AlbumsApi.ts index d0dc609fe7b..c39129dfa93 100644 --- a/packages/libs/src/sdk/api/albums/AlbumsApi.ts +++ b/packages/libs/src/sdk/api/albums/AlbumsApi.ts @@ -12,9 +12,10 @@ import type { } from '../../services/EntityManager/types' import type { LoggerService } from '../../services/Logger' import { parseParams } from '../../utils/parseParams' +import { prepareSplits } from '../../utils/preparePaymentSplits' import { - instanceOfPurchaseGate, - UsdcGate, + ExtendedPaymentSplit, + instanceOfExtendedPurchaseGate, type Configuration } from '../generated/default' import { PlaylistsApi } from '../playlists/PlaylistsApi' @@ -28,6 +29,8 @@ import { FavoriteAlbumSchema, getAlbumRequest, getAlbumTracksRequest, + GetPurchaseAlbumTransactionRequest, + GetPurchaseAlbumTransactionSchema, PurchaseAlbumRequest, PurchaseAlbumSchema, RepostAlbumRequest, @@ -231,31 +234,34 @@ export class AlbumsApi { } /** - * Purchases stream access to an album + * Gets the Solana transaction that purchases the album * * @hidden */ - async purchase(params: PurchaseAlbumRequest) { + async getPurchaseAlbumTransaction( + params: GetPurchaseAlbumTransactionRequest + ) { const { userId, albumId, price: priceNumber, extraAmount: extraAmountNumber = 0, - walletAdapter - } = await parseParams('purchase', PurchaseAlbumSchema)(params) + wallet + } = await parseParams( + 'getPurchaseAlbumTransaction', + GetPurchaseAlbumTransactionSchema + )(params) const contentType = 'album' const mint = 'USDC' // Fetch album this.logger.debug('Fetching album...', { albumId }) - const { data: albums } = await this.getAlbum({ + const { data: album } = await this.playlistsApi.getPlaylistAccessInfo({ userId: params.userId, // use hashed userId - albumId: params.albumId // use hashed albumId + playlistId: params.albumId // use hashed albumId }) - const album = albums ? albums[0] : undefined - // Validate purchase attempt if (!album) { throw new Error('Album not found.') @@ -265,18 +271,18 @@ export class AlbumsApi { throw new Error('Attempted to purchase free album.') } - if (album.user.id === params.userId) { + if (album.userId === params.userId) { throw new Error('Attempted to purchase own album.') } - let numberSplits: UsdcGate['splits'] = {} + let numberSplits: ExtendedPaymentSplit[] = [] let centPrice: number const accessType: 'stream' | 'download' = 'stream' // Get conditions if ( album.streamConditions && - instanceOfPurchaseGate(album.streamConditions) + instanceOfExtendedPurchaseGate(album.streamConditions) ) { centPrice = album.streamConditions.usdcPurchase.price numberSplits = album.streamConditions.usdcPurchase.splits @@ -295,40 +301,17 @@ export class AlbumsApi { throw new Error('Track price increased.') } - let extraAmount = USDC(extraAmountNumber).value + const 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 splits = await prepareSplits({ + splits: numberSplits, + extraAmount, + claimableTokensClient: this.claimableTokensClient, + logger: this.logger + }) + this.logger.debug('Calculated splits:', splits) const routeInstruction = await this.paymentRouterClient.createRouteInstruction({ @@ -345,26 +328,22 @@ export class AlbumsApi { accessType }) - if (walletAdapter) { - this.logger.debug('Using connected wallet to purchase...') - if (!walletAdapter.publicKey) { - throw new Error('Could not get connected wallet address') - } + if (wallet) { + this.logger.debug('Using provided wallet to purchase...', { + wallet: wallet.toBase58() + }) // Use the specified Solana wallet const transferInstruction = await this.paymentRouterClient.createTransferInstruction({ - sourceWallet: walletAdapter.publicKey, + sourceWallet: wallet, total, mint }) const transaction = await this.paymentRouterClient.buildTransaction({ - feePayer: walletAdapter.publicKey, + feePayer: wallet, instructions: [transferInstruction, routeInstruction, memoInstruction] }) - return await walletAdapter.sendTransaction( - transaction, - this.paymentRouterClient.connection - ) + return transaction } else { // Use the authed wallet's userbank and relay const ethWallet = await this.auth.getAddress() @@ -401,7 +380,29 @@ export class AlbumsApi { memoInstruction ] }) - return await this.paymentRouterClient.sendTransaction(transaction) + return transaction + } + } + + /** + * Purchases stream access to an album + * + * @hidden + */ + public async purchaseAlbum(params: PurchaseAlbumRequest) { + await parseParams('purchaseAlbum', PurchaseAlbumSchema)(params) + const transaction = await this.getPurchaseAlbumTransaction(params) + if (params.walletAdapter) { + if (!params.walletAdapter.publicKey) { + throw new Error( + 'Param walletAdapter was specified, but no wallet selected' + ) + } + return await params.walletAdapter.sendTransaction( + transaction, + this.paymentRouterClient.connection + ) } + return this.paymentRouterClient.sendTransaction(transaction) } } diff --git a/packages/libs/src/sdk/api/albums/types.ts b/packages/libs/src/sdk/api/albums/types.ts index ce5175c9eef..cd4fe52b8c1 100644 --- a/packages/libs/src/sdk/api/albums/types.ts +++ b/packages/libs/src/sdk/api/albums/types.ts @@ -1,6 +1,7 @@ import { WalletAdapter } from '@solana/wallet-adapter-base' import { z } from 'zod' +import { PublicKeySchema } from '../../services/Solana' import { DDEXResourceContributor, DDEXCopyright } from '../../types/DDEX' import { AudioFile, ImageFile } from '../../types/File' import { Genre } from '../../types/Genre' @@ -150,27 +151,43 @@ export const UnrepostAlbumSchema = z export type UnrepostAlbumRequest = z.input +const PurchaseAlbumSchemaBase = 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() +}) + +export const GetPurchaseAlbumTransactionSchema = z + .object({ + /** A wallet to use to purchase (defaults to the authed user's user bank if not specified) */ + wallet: PublicKeySchema.optional() + }) + .merge(PurchaseAlbumSchemaBase) + .strict() + +export type GetPurchaseAlbumTransactionRequest = z.input< + typeof GetPurchaseAlbumTransactionSchema +> + 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>() .optional() }) + .merge(PurchaseAlbumSchemaBase) .strict() export type PurchaseAlbumRequest = z.input diff --git a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES index a692ae56b19..52e530cb701 100644 --- a/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/libs/src/sdk/api/generated/default/.openapi-generator/FILES @@ -9,7 +9,7 @@ apis/UsersApi.ts apis/index.ts index.ts models/Access.ts -models/AccessGate.ts +models/AccessInfoResponse.ts models/Activity.ts models/AuthorizedApp.ts models/AuthorizedApps.ts @@ -24,6 +24,10 @@ models/DeveloperApp.ts models/DeveloperAppResponse.ts models/DeveloperApps.ts models/EncodedUserId.ts +models/ExtendedAccessGate.ts +models/ExtendedPaymentSplit.ts +models/ExtendedPurchaseGate.ts +models/ExtendedUsdcGate.ts models/Favorite.ts models/FavoritesResponse.ts models/FollowGate.ts @@ -41,7 +45,6 @@ models/PlaylistResponse.ts models/PlaylistSearchResult.ts models/PlaylistTracksResponse.ts models/ProfilePicture.ts -models/PurchaseGate.ts models/RelatedArtistResponse.ts models/RemixParent.ts models/Reposts.ts @@ -53,6 +56,7 @@ models/Tip.ts models/TipGate.ts models/TopListener.ts models/Track.ts +models/TrackAccessInfo.ts models/TrackArtwork.ts models/TrackElement.ts models/TrackInspect.ts @@ -60,7 +64,6 @@ models/TrackResponse.ts models/TrackSearch.ts models/TracksResponse.ts models/TrendingPlaylistsResponse.ts -models/UsdcGate.ts models/User.ts models/UserAssociatedWalletResponse.ts models/UserResponse.ts diff --git a/packages/libs/src/sdk/api/generated/default/apis/PlaylistsApi.ts b/packages/libs/src/sdk/api/generated/default/apis/PlaylistsApi.ts index e16c1c780d0..fa2edcb680e 100644 --- a/packages/libs/src/sdk/api/generated/default/apis/PlaylistsApi.ts +++ b/packages/libs/src/sdk/api/generated/default/apis/PlaylistsApi.ts @@ -16,12 +16,15 @@ import * as runtime from '../runtime'; import type { + AccessInfoResponse, PlaylistResponse, PlaylistSearchResult, PlaylistTracksResponse, TrendingPlaylistsResponse, } from '../models'; import { + AccessInfoResponseFromJSON, + AccessInfoResponseToJSON, PlaylistResponseFromJSON, PlaylistResponseToJSON, PlaylistSearchResultFromJSON, @@ -37,6 +40,11 @@ export interface GetPlaylistRequest { userId?: string; } +export interface GetPlaylistAccessInfoRequest { + playlistId: string; + userId?: string; +} + export interface GetPlaylistByHandleAndSlugRequest { handle: string; slug: string; @@ -95,6 +103,41 @@ export class PlaylistsApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets information necessary to access the playlist and what access the given user has. + */ + async getPlaylistAccessInfoRaw(params: GetPlaylistAccessInfoRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.playlistId === null || params.playlistId === undefined) { + throw new runtime.RequiredError('playlistId','Required parameter params.playlistId was null or undefined when calling getPlaylistAccessInfo.'); + } + + const queryParameters: any = {}; + + if (params.userId !== undefined) { + queryParameters['user_id'] = params.userId; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/playlists/{playlist_id}/access-info`.replace(`{${"playlist_id"}}`, encodeURIComponent(String(params.playlistId))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => AccessInfoResponseFromJSON(jsonValue)); + } + + /** + * Gets information necessary to access the playlist and what access the given user has. + */ + async getPlaylistAccessInfo(params: GetPlaylistAccessInfoRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getPlaylistAccessInfoRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Get a playlist by handle and slug diff --git a/packages/libs/src/sdk/api/generated/default/apis/TracksApi.ts b/packages/libs/src/sdk/api/generated/default/apis/TracksApi.ts index 054897a2082..bcbed60f0e9 100644 --- a/packages/libs/src/sdk/api/generated/default/apis/TracksApi.ts +++ b/packages/libs/src/sdk/api/generated/default/apis/TracksApi.ts @@ -16,6 +16,7 @@ import * as runtime from '../runtime'; import type { + AccessInfoResponse, TopListener, TrackInspect, TrackResponse, @@ -23,6 +24,8 @@ import type { TracksResponse, } from '../models'; import { + AccessInfoResponseFromJSON, + AccessInfoResponseToJSON, TopListenerFromJSON, TopListenerToJSON, TrackInspectFromJSON, @@ -53,6 +56,11 @@ export interface GetTrackRequest { trackId: string; } +export interface GetTrackAccessInfoRequest { + trackId: string; + userId?: string; +} + export interface GetTrackTopListenersRequest { trackId: string; offset?: number; @@ -213,6 +221,41 @@ export class TracksApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets the information necessary to access the track and what access the given user has. + */ + async getTrackAccessInfoRaw(params: GetTrackAccessInfoRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.trackId === null || params.trackId === undefined) { + throw new runtime.RequiredError('trackId','Required parameter params.trackId was null or undefined when calling getTrackAccessInfo.'); + } + + const queryParameters: any = {}; + + if (params.userId !== undefined) { + queryParameters['user_id'] = params.userId; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/tracks/{track_id}/access-info`.replace(`{${"track_id"}}`, encodeURIComponent(String(params.trackId))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => AccessInfoResponseFromJSON(jsonValue)); + } + + /** + * Gets the information necessary to access the track and what access the given user has. + */ + async getTrackAccessInfo(params: GetTrackAccessInfoRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getTrackAccessInfoRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Get the users that have listened to a track the most diff --git a/packages/libs/src/sdk/api/generated/default/models/AccessInfoResponse.ts b/packages/libs/src/sdk/api/generated/default/models/AccessInfoResponse.ts new file mode 100644 index 00000000000..f37fd80bd55 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/AccessInfoResponse.ts @@ -0,0 +1,73 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { TrackAccessInfo } from './TrackAccessInfo'; +import { + TrackAccessInfoFromJSON, + TrackAccessInfoFromJSONTyped, + TrackAccessInfoToJSON, +} from './TrackAccessInfo'; + +/** + * + * @export + * @interface AccessInfoResponse + */ +export interface AccessInfoResponse { + /** + * + * @type {TrackAccessInfo} + * @memberof AccessInfoResponse + */ + data?: TrackAccessInfo; +} + +/** + * Check if a given object implements the AccessInfoResponse interface. + */ +export function instanceOfAccessInfoResponse(value: object): value is AccessInfoResponse { + let isInstance = true; + + return isInstance; +} + +export function AccessInfoResponseFromJSON(json: any): AccessInfoResponse { + return AccessInfoResponseFromJSONTyped(json, false); +} + +export function AccessInfoResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): AccessInfoResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': !exists(json, 'data') ? undefined : TrackAccessInfoFromJSON(json['data']), + }; +} + +export function AccessInfoResponseToJSON(value?: AccessInfoResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': TrackAccessInfoToJSON(value.data), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/AccessGate.ts b/packages/libs/src/sdk/api/generated/default/models/ExtendedAccessGate.ts similarity index 57% rename from packages/libs/src/sdk/api/generated/default/models/AccessGate.ts rename to packages/libs/src/sdk/api/generated/default/models/ExtendedAccessGate.ts index f27d886ea79..5f6d2824d81 100644 --- a/packages/libs/src/sdk/api/generated/default/models/AccessGate.ts +++ b/packages/libs/src/sdk/api/generated/default/models/ExtendedAccessGate.ts @@ -13,6 +13,13 @@ * Do not edit the class manually. */ +import { + ExtendedPurchaseGate, + instanceOfExtendedPurchaseGate, + ExtendedPurchaseGateFromJSON, + ExtendedPurchaseGateFromJSONTyped, + ExtendedPurchaseGateToJSON, +} from './ExtendedPurchaseGate'; import { FollowGate, instanceOfFollowGate, @@ -27,13 +34,6 @@ import { NftGateFromJSONTyped, NftGateToJSON, } from './NftGate'; -import { - PurchaseGate, - instanceOfPurchaseGate, - PurchaseGateFromJSON, - PurchaseGateFromJSONTyped, - PurchaseGateToJSON, -} from './PurchaseGate'; import { TipGate, instanceOfTipGate, @@ -43,24 +43,24 @@ import { } from './TipGate'; /** - * @type AccessGate + * @type ExtendedAccessGate * * @export */ -export type AccessGate = FollowGate | NftGate | PurchaseGate | TipGate; +export type ExtendedAccessGate = ExtendedPurchaseGate | FollowGate | NftGate | TipGate; -export function AccessGateFromJSON(json: any): AccessGate { - return AccessGateFromJSONTyped(json, false); +export function ExtendedAccessGateFromJSON(json: any): ExtendedAccessGate { + return ExtendedAccessGateFromJSONTyped(json, false); } -export function AccessGateFromJSONTyped(json: any, ignoreDiscriminator: boolean): AccessGate { +export function ExtendedAccessGateFromJSONTyped(json: any, ignoreDiscriminator: boolean): ExtendedAccessGate { if ((json === undefined) || (json === null)) { return json; } - return { ...FollowGateFromJSONTyped(json, true), ...NftGateFromJSONTyped(json, true), ...PurchaseGateFromJSONTyped(json, true), ...TipGateFromJSONTyped(json, true) }; + return { ...ExtendedPurchaseGateFromJSONTyped(json, true), ...FollowGateFromJSONTyped(json, true), ...NftGateFromJSONTyped(json, true), ...TipGateFromJSONTyped(json, true) }; } -export function AccessGateToJSON(value?: AccessGate | null): any { +export function ExtendedAccessGateToJSON(value?: ExtendedAccessGate | null): any { if (value === undefined) { return undefined; } @@ -68,15 +68,15 @@ export function AccessGateToJSON(value?: AccessGate | null): any { return null; } + if (instanceOfExtendedPurchaseGate(value)) { + return ExtendedPurchaseGateToJSON(value as ExtendedPurchaseGate); + } if (instanceOfFollowGate(value)) { return FollowGateToJSON(value as FollowGate); } if (instanceOfNftGate(value)) { return NftGateToJSON(value as NftGate); } - if (instanceOfPurchaseGate(value)) { - return PurchaseGateToJSON(value as PurchaseGate); - } if (instanceOfTipGate(value)) { return TipGateToJSON(value as TipGate); } diff --git a/packages/libs/src/sdk/api/generated/default/models/ExtendedPaymentSplit.ts b/packages/libs/src/sdk/api/generated/default/models/ExtendedPaymentSplit.ts new file mode 100644 index 00000000000..0f825d5b499 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/ExtendedPaymentSplit.ts @@ -0,0 +1,103 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface ExtendedPaymentSplit + */ +export interface ExtendedPaymentSplit { + /** + * + * @type {number} + * @memberof ExtendedPaymentSplit + */ + userId: number; + /** + * + * @type {number} + * @memberof ExtendedPaymentSplit + */ + percentage: number; + /** + * + * @type {string} + * @memberof ExtendedPaymentSplit + */ + ethWallet: string; + /** + * + * @type {string} + * @memberof ExtendedPaymentSplit + */ + payoutWallet: string; + /** + * + * @type {number} + * @memberof ExtendedPaymentSplit + */ + amount: number; +} + +/** + * Check if a given object implements the ExtendedPaymentSplit interface. + */ +export function instanceOfExtendedPaymentSplit(value: object): value is ExtendedPaymentSplit { + let isInstance = true; + isInstance = isInstance && "userId" in value && value["userId"] !== undefined; + isInstance = isInstance && "percentage" in value && value["percentage"] !== undefined; + isInstance = isInstance && "ethWallet" in value && value["ethWallet"] !== undefined; + isInstance = isInstance && "payoutWallet" in value && value["payoutWallet"] !== undefined; + isInstance = isInstance && "amount" in value && value["amount"] !== undefined; + + return isInstance; +} + +export function ExtendedPaymentSplitFromJSON(json: any): ExtendedPaymentSplit { + return ExtendedPaymentSplitFromJSONTyped(json, false); +} + +export function ExtendedPaymentSplitFromJSONTyped(json: any, ignoreDiscriminator: boolean): ExtendedPaymentSplit { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'userId': json['user_id'], + 'percentage': json['percentage'], + 'ethWallet': json['eth_wallet'], + 'payoutWallet': json['payout_wallet'], + 'amount': json['amount'], + }; +} + +export function ExtendedPaymentSplitToJSON(value?: ExtendedPaymentSplit | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'user_id': value.userId, + 'percentage': value.percentage, + 'eth_wallet': value.ethWallet, + 'payout_wallet': value.payoutWallet, + 'amount': value.amount, + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/ExtendedPurchaseGate.ts b/packages/libs/src/sdk/api/generated/default/models/ExtendedPurchaseGate.ts new file mode 100644 index 00000000000..34f0b772a3f --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/ExtendedPurchaseGate.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ExtendedUsdcGate } from './ExtendedUsdcGate'; +import { + ExtendedUsdcGateFromJSON, + ExtendedUsdcGateFromJSONTyped, + ExtendedUsdcGateToJSON, +} from './ExtendedUsdcGate'; + +/** + * + * @export + * @interface ExtendedPurchaseGate + */ +export interface ExtendedPurchaseGate { + /** + * Must pay the total price and split to the given addresses to unlock + * @type {ExtendedUsdcGate} + * @memberof ExtendedPurchaseGate + */ + usdcPurchase: ExtendedUsdcGate; +} + +/** + * Check if a given object implements the ExtendedPurchaseGate interface. + */ +export function instanceOfExtendedPurchaseGate(value: object): value is ExtendedPurchaseGate { + let isInstance = true; + isInstance = isInstance && "usdcPurchase" in value && value["usdcPurchase"] !== undefined; + + return isInstance; +} + +export function ExtendedPurchaseGateFromJSON(json: any): ExtendedPurchaseGate { + return ExtendedPurchaseGateFromJSONTyped(json, false); +} + +export function ExtendedPurchaseGateFromJSONTyped(json: any, ignoreDiscriminator: boolean): ExtendedPurchaseGate { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'usdcPurchase': ExtendedUsdcGateFromJSON(json['usdc_purchase']), + }; +} + +export function ExtendedPurchaseGateToJSON(value?: ExtendedPurchaseGate | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'usdc_purchase': ExtendedUsdcGateToJSON(value.usdcPurchase), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/ExtendedUsdcGate.ts b/packages/libs/src/sdk/api/generated/default/models/ExtendedUsdcGate.ts new file mode 100644 index 00000000000..e591ff8a605 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/ExtendedUsdcGate.ts @@ -0,0 +1,83 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ExtendedPaymentSplit } from './ExtendedPaymentSplit'; +import { + ExtendedPaymentSplitFromJSON, + ExtendedPaymentSplitFromJSONTyped, + ExtendedPaymentSplitToJSON, +} from './ExtendedPaymentSplit'; + +/** + * + * @export + * @interface ExtendedUsdcGate + */ +export interface ExtendedUsdcGate { + /** + * + * @type {number} + * @memberof ExtendedUsdcGate + */ + price: number; + /** + * + * @type {Array} + * @memberof ExtendedUsdcGate + */ + splits: Array; +} + +/** + * Check if a given object implements the ExtendedUsdcGate interface. + */ +export function instanceOfExtendedUsdcGate(value: object): value is ExtendedUsdcGate { + let isInstance = true; + isInstance = isInstance && "price" in value && value["price"] !== undefined; + isInstance = isInstance && "splits" in value && value["splits"] !== undefined; + + return isInstance; +} + +export function ExtendedUsdcGateFromJSON(json: any): ExtendedUsdcGate { + return ExtendedUsdcGateFromJSONTyped(json, false); +} + +export function ExtendedUsdcGateFromJSONTyped(json: any, ignoreDiscriminator: boolean): ExtendedUsdcGate { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'price': json['price'], + 'splits': ((json['splits'] as Array).map(ExtendedPaymentSplitFromJSON)), + }; +} + +export function ExtendedUsdcGateToJSON(value?: ExtendedUsdcGate | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'price': value.price, + 'splits': ((value.splits as Array).map(ExtendedPaymentSplitToJSON)), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/Playlist.ts b/packages/libs/src/sdk/api/generated/default/models/Playlist.ts index 6b7798c1a2e..84853e98f7e 100644 --- a/packages/libs/src/sdk/api/generated/default/models/Playlist.ts +++ b/packages/libs/src/sdk/api/generated/default/models/Playlist.ts @@ -20,12 +20,6 @@ import { AccessFromJSONTyped, AccessToJSON, } from './Access'; -import type { AccessGate } from './AccessGate'; -import { - AccessGateFromJSON, - AccessGateFromJSONTyped, - AccessGateToJSON, -} from './AccessGate'; import type { PlaylistAddedTimestamp } from './PlaylistAddedTimestamp'; import { PlaylistAddedTimestampFromJSON, @@ -51,12 +45,6 @@ import { * @interface Playlist */ export interface Playlist { - /** - * - * @type {number} - * @memberof Playlist - */ - blocknumber: number; /** * * @type {PlaylistArtwork} @@ -147,18 +135,6 @@ export interface Playlist { * @memberof Playlist */ upc?: string; - /** - * - * @type {boolean} - * @memberof Playlist - */ - isStreamGated: boolean; - /** - * How to unlock stream access to the track - * @type {AccessGate} - * @memberof Playlist - */ - streamConditions?: AccessGate; } /** @@ -166,7 +142,6 @@ export interface Playlist { */ export function instanceOfPlaylist(value: object): value is Playlist { let isInstance = true; - isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "id" in value && value["id"] !== undefined; isInstance = isInstance && "isAlbum" in value && value["isAlbum"] !== undefined; isInstance = isInstance && "isImageAutogenerated" in value && value["isImageAutogenerated"] !== undefined; @@ -176,7 +151,6 @@ export function instanceOfPlaylist(value: object): value is Playlist { isInstance = isInstance && "favoriteCount" in value && value["favoriteCount"] !== undefined; isInstance = isInstance && "totalPlayCount" in value && value["totalPlayCount"] !== undefined; isInstance = isInstance && "user" in value && value["user"] !== undefined; - isInstance = isInstance && "isStreamGated" in value && value["isStreamGated"] !== undefined; return isInstance; } @@ -191,7 +165,6 @@ export function PlaylistFromJSONTyped(json: any, ignoreDiscriminator: boolean): } return { - 'blocknumber': json['blocknumber'], 'artwork': !exists(json, 'artwork') ? undefined : PlaylistArtworkFromJSON(json['artwork']), 'description': !exists(json, 'description') ? undefined : json['description'], 'permalink': !exists(json, 'permalink') ? undefined : json['permalink'], @@ -207,8 +180,6 @@ export function PlaylistFromJSONTyped(json: any, ignoreDiscriminator: boolean): 'ddexApp': !exists(json, 'ddex_app') ? undefined : json['ddex_app'], 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), 'upc': !exists(json, 'upc') ? undefined : json['upc'], - 'isStreamGated': json['is_stream_gated'], - 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), }; } @@ -221,7 +192,6 @@ export function PlaylistToJSON(value?: Playlist | null): any { } return { - 'blocknumber': value.blocknumber, 'artwork': PlaylistArtworkToJSON(value.artwork), 'description': value.description, 'permalink': value.permalink, @@ -237,8 +207,6 @@ export function PlaylistToJSON(value?: Playlist | null): any { 'ddex_app': value.ddexApp, 'access': AccessToJSON(value.access), 'upc': value.upc, - 'is_stream_gated': value.isStreamGated, - 'stream_conditions': AccessGateToJSON(value.streamConditions), }; } diff --git a/packages/libs/src/sdk/api/generated/default/models/PurchaseGate.ts b/packages/libs/src/sdk/api/generated/default/models/PurchaseGate.ts deleted file mode 100644 index 42fb48b2cec..00000000000 --- a/packages/libs/src/sdk/api/generated/default/models/PurchaseGate.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @ts-nocheck -/** - * API - * Audius V1 API - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from '../runtime'; -import type { UsdcGate } from './UsdcGate'; -import { - UsdcGateFromJSON, - UsdcGateFromJSONTyped, - UsdcGateToJSON, -} from './UsdcGate'; - -/** - * - * @export - * @interface PurchaseGate - */ -export interface PurchaseGate { - /** - * Must pay the total price and split to the given addresses to unlock - * @type {UsdcGate} - * @memberof PurchaseGate - */ - usdcPurchase: UsdcGate; -} - -/** - * Check if a given object implements the PurchaseGate interface. - */ -export function instanceOfPurchaseGate(value: object): value is PurchaseGate { - let isInstance = true; - isInstance = isInstance && "usdcPurchase" in value && value["usdcPurchase"] !== undefined; - - return isInstance; -} - -export function PurchaseGateFromJSON(json: any): PurchaseGate { - return PurchaseGateFromJSONTyped(json, false); -} - -export function PurchaseGateFromJSONTyped(json: any, ignoreDiscriminator: boolean): PurchaseGate { - if ((json === undefined) || (json === null)) { - return json; - } - return { - - 'usdcPurchase': UsdcGateFromJSON(json['usdc_purchase']), - }; -} - -export function PurchaseGateToJSON(value?: PurchaseGate | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - - 'usdc_purchase': UsdcGateToJSON(value.usdcPurchase), - }; -} - diff --git a/packages/libs/src/sdk/api/generated/default/models/Track.ts b/packages/libs/src/sdk/api/generated/default/models/Track.ts index b0292fe9a40..dcd93377cf1 100644 --- a/packages/libs/src/sdk/api/generated/default/models/Track.ts +++ b/packages/libs/src/sdk/api/generated/default/models/Track.ts @@ -14,18 +14,6 @@ */ import { exists, mapValues } from '../runtime'; -import type { Access } from './Access'; -import { - AccessFromJSON, - AccessFromJSONTyped, - AccessToJSON, -} from './Access'; -import type { AccessGate } from './AccessGate'; -import { - AccessGateFromJSON, - AccessGateFromJSONTyped, - AccessGateToJSON, -} from './AccessGate'; import type { RemixParent } from './RemixParent'; import { RemixParentFromJSON, @@ -51,24 +39,12 @@ import { * @interface Track */ export interface Track { - /** - * Describes what access the given user has - * @type {Access} - * @memberof Track - */ - access?: Access; /** * * @type {TrackArtwork} * @memberof Track */ artwork?: TrackArtwork; - /** - * The blocknumber this track was last updated - * @type {number} - * @memberof Track - */ - blocknumber: number; /** * * @type {string} @@ -207,30 +183,6 @@ export interface Track { * @memberof Track */ playlistsContainingTrack?: Array; - /** - * Whether or not the owner has restricted streaming behind an access gate - * @type {boolean} - * @memberof Track - */ - isStreamGated?: boolean; - /** - * How to unlock stream access to the track - * @type {AccessGate} - * @memberof Track - */ - streamConditions?: AccessGate; - /** - * Whether or not the owner has restricted downloading behind an access gate - * @type {boolean} - * @memberof Track - */ - isDownloadGated?: boolean; - /** - * How to unlock the track download - * @type {AccessGate} - * @memberof Track - */ - downloadConditions?: AccessGate; } /** @@ -238,7 +190,6 @@ export interface Track { */ export function instanceOfTrack(value: object): value is Track { let isInstance = true; - isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "id" in value && value["id"] !== undefined; isInstance = isInstance && "repostCount" in value && value["repostCount"] !== undefined; isInstance = isInstance && "favoriteCount" in value && value["favoriteCount"] !== undefined; @@ -260,9 +211,7 @@ export function TrackFromJSONTyped(json: any, ignoreDiscriminator: boolean): Tra } return { - 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), 'artwork': !exists(json, 'artwork') ? undefined : TrackArtworkFromJSON(json['artwork']), - 'blocknumber': json['blocknumber'], 'description': !exists(json, 'description') ? undefined : json['description'], 'genre': !exists(json, 'genre') ? undefined : json['genre'], 'id': json['id'], @@ -286,10 +235,6 @@ export function TrackFromJSONTyped(json: any, ignoreDiscriminator: boolean): Tra 'isStreamable': !exists(json, 'is_streamable') ? undefined : json['is_streamable'], 'ddexApp': !exists(json, 'ddex_app') ? undefined : json['ddex_app'], 'playlistsContainingTrack': !exists(json, 'playlists_containing_track') ? undefined : json['playlists_containing_track'], - 'isStreamGated': !exists(json, 'is_stream_gated') ? undefined : json['is_stream_gated'], - 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), - 'isDownloadGated': !exists(json, 'is_download_gated') ? undefined : json['is_download_gated'], - 'downloadConditions': !exists(json, 'download_conditions') ? undefined : AccessGateFromJSON(json['download_conditions']), }; } @@ -302,9 +247,7 @@ export function TrackToJSON(value?: Track | null): any { } return { - 'access': AccessToJSON(value.access), 'artwork': TrackArtworkToJSON(value.artwork), - 'blocknumber': value.blocknumber, 'description': value.description, 'genre': value.genre, 'id': value.id, @@ -328,10 +271,6 @@ export function TrackToJSON(value?: Track | null): any { 'is_streamable': value.isStreamable, 'ddex_app': value.ddexApp, 'playlists_containing_track': value.playlistsContainingTrack, - 'is_stream_gated': value.isStreamGated, - 'stream_conditions': AccessGateToJSON(value.streamConditions), - 'is_download_gated': value.isDownloadGated, - 'download_conditions': AccessGateToJSON(value.downloadConditions), }; } diff --git a/packages/libs/src/sdk/api/generated/default/models/TrackAccessInfo.ts b/packages/libs/src/sdk/api/generated/default/models/TrackAccessInfo.ts new file mode 100644 index 00000000000..aca6bd3ccb7 --- /dev/null +++ b/packages/libs/src/sdk/api/generated/default/models/TrackAccessInfo.ts @@ -0,0 +1,129 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { Access } from './Access'; +import { + AccessFromJSON, + AccessFromJSONTyped, + AccessToJSON, +} from './Access'; +import type { ExtendedAccessGate } from './ExtendedAccessGate'; +import { + ExtendedAccessGateFromJSON, + ExtendedAccessGateFromJSONTyped, + ExtendedAccessGateToJSON, +} from './ExtendedAccessGate'; + +/** + * + * @export + * @interface TrackAccessInfo + */ +export interface TrackAccessInfo { + /** + * Describes what access the given user has + * @type {Access} + * @memberof TrackAccessInfo + */ + access?: Access; + /** + * The user ID of the owner of this track + * @type {string} + * @memberof TrackAccessInfo + */ + userId: string; + /** + * The blocknumber this track was last updated + * @type {number} + * @memberof TrackAccessInfo + */ + blocknumber: number; + /** + * Whether or not the owner has restricted streaming behind an access gate + * @type {boolean} + * @memberof TrackAccessInfo + */ + isStreamGated?: boolean; + /** + * How to unlock stream access to the track + * @type {ExtendedAccessGate} + * @memberof TrackAccessInfo + */ + streamConditions?: ExtendedAccessGate; + /** + * Whether or not the owner has restricted downloading behind an access gate + * @type {boolean} + * @memberof TrackAccessInfo + */ + isDownloadGated?: boolean; + /** + * How to unlock the track download + * @type {ExtendedAccessGate} + * @memberof TrackAccessInfo + */ + downloadConditions?: ExtendedAccessGate; +} + +/** + * Check if a given object implements the TrackAccessInfo interface. + */ +export function instanceOfTrackAccessInfo(value: object): value is TrackAccessInfo { + let isInstance = true; + isInstance = isInstance && "userId" in value && value["userId"] !== undefined; + isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; + + return isInstance; +} + +export function TrackAccessInfoFromJSON(json: any): TrackAccessInfo { + return TrackAccessInfoFromJSONTyped(json, false); +} + +export function TrackAccessInfoFromJSONTyped(json: any, ignoreDiscriminator: boolean): TrackAccessInfo { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), + 'userId': json['user_id'], + 'blocknumber': json['blocknumber'], + 'isStreamGated': !exists(json, 'is_stream_gated') ? undefined : json['is_stream_gated'], + 'streamConditions': !exists(json, 'stream_conditions') ? undefined : ExtendedAccessGateFromJSON(json['stream_conditions']), + 'isDownloadGated': !exists(json, 'is_download_gated') ? undefined : json['is_download_gated'], + 'downloadConditions': !exists(json, 'download_conditions') ? undefined : ExtendedAccessGateFromJSON(json['download_conditions']), + }; +} + +export function TrackAccessInfoToJSON(value?: TrackAccessInfo | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'access': AccessToJSON(value.access), + 'user_id': value.userId, + 'blocknumber': value.blocknumber, + 'is_stream_gated': value.isStreamGated, + 'stream_conditions': ExtendedAccessGateToJSON(value.streamConditions), + 'is_download_gated': value.isDownloadGated, + 'download_conditions': ExtendedAccessGateToJSON(value.downloadConditions), + }; +} + diff --git a/packages/libs/src/sdk/api/generated/default/models/UsdcGate.ts b/packages/libs/src/sdk/api/generated/default/models/UsdcGate.ts deleted file mode 100644 index a259488e079..00000000000 --- a/packages/libs/src/sdk/api/generated/default/models/UsdcGate.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -// @ts-nocheck -/** - * API - * Audius V1 API - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from '../runtime'; -/** - * - * @export - * @interface UsdcGate - */ -export interface UsdcGate { - /** - * - * @type {{ [key: string]: number; }} - * @memberof UsdcGate - */ - splits: { [key: string]: number; }; - /** - * - * @type {number} - * @memberof UsdcGate - */ - price: number; -} - -/** - * Check if a given object implements the UsdcGate interface. - */ -export function instanceOfUsdcGate(value: object): value is UsdcGate { - let isInstance = true; - isInstance = isInstance && "splits" in value && value["splits"] !== undefined; - isInstance = isInstance && "price" in value && value["price"] !== undefined; - - return isInstance; -} - -export function UsdcGateFromJSON(json: any): UsdcGate { - return UsdcGateFromJSONTyped(json, false); -} - -export function UsdcGateFromJSONTyped(json: any, ignoreDiscriminator: boolean): UsdcGate { - if ((json === undefined) || (json === null)) { - return json; - } - return { - - 'splits': json['splits'], - 'price': json['price'], - }; -} - -export function UsdcGateToJSON(value?: UsdcGate | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - - 'splits': value.splits, - 'price': value.price, - }; -} - diff --git a/packages/libs/src/sdk/api/generated/default/models/index.ts b/packages/libs/src/sdk/api/generated/default/models/index.ts index cba5ff49cfe..39c943cea0f 100644 --- a/packages/libs/src/sdk/api/generated/default/models/index.ts +++ b/packages/libs/src/sdk/api/generated/default/models/index.ts @@ -1,7 +1,7 @@ /* tslint:disable */ /* eslint-disable */ export * from './Access'; -export * from './AccessGate'; +export * from './AccessInfoResponse'; export * from './Activity'; export * from './AuthorizedApp'; export * from './AuthorizedApps'; @@ -16,6 +16,10 @@ export * from './DeveloperApp'; export * from './DeveloperAppResponse'; export * from './DeveloperApps'; export * from './EncodedUserId'; +export * from './ExtendedAccessGate'; +export * from './ExtendedPaymentSplit'; +export * from './ExtendedPurchaseGate'; +export * from './ExtendedUsdcGate'; export * from './Favorite'; export * from './FavoritesResponse'; export * from './FollowGate'; @@ -33,7 +37,6 @@ export * from './PlaylistResponse'; export * from './PlaylistSearchResult'; export * from './PlaylistTracksResponse'; export * from './ProfilePicture'; -export * from './PurchaseGate'; export * from './RelatedArtistResponse'; export * from './RemixParent'; export * from './Reposts'; @@ -45,6 +48,7 @@ export * from './Tip'; export * from './TipGate'; export * from './TopListener'; export * from './Track'; +export * from './TrackAccessInfo'; export * from './TrackArtwork'; export * from './TrackElement'; export * from './TrackInspect'; @@ -52,7 +56,6 @@ export * from './TrackResponse'; export * from './TrackSearch'; export * from './TracksResponse'; export * from './TrendingPlaylistsResponse'; -export * from './UsdcGate'; export * from './User'; export * from './UserAssociatedWalletResponse'; export * from './UserResponse'; diff --git a/packages/libs/src/sdk/api/generated/full/models/PlaylistFull.ts b/packages/libs/src/sdk/api/generated/full/models/PlaylistFull.ts index 64751153bdd..4c72bcd2f5c 100644 --- a/packages/libs/src/sdk/api/generated/full/models/PlaylistFull.ts +++ b/packages/libs/src/sdk/api/generated/full/models/PlaylistFull.ts @@ -69,12 +69,6 @@ import { * @interface PlaylistFull */ export interface PlaylistFull { - /** - * - * @type {number} - * @memberof PlaylistFull - */ - blocknumber: number; /** * * @type {PlaylistArtwork} @@ -167,16 +161,10 @@ export interface PlaylistFull { upc?: string; /** * - * @type {boolean} - * @memberof PlaylistFull - */ - isStreamGated: boolean; - /** - * How to unlock stream access to the track - * @type {AccessGate} + * @type {number} * @memberof PlaylistFull */ - streamConditions?: AccessGate; + blocknumber: number; /** * * @type {string} @@ -267,6 +255,18 @@ export interface PlaylistFull { * @memberof PlaylistFull */ trackCount: number; + /** + * + * @type {boolean} + * @memberof PlaylistFull + */ + isStreamGated: boolean; + /** + * How to unlock stream access to the track + * @type {AccessGate} + * @memberof PlaylistFull + */ + streamConditions?: AccessGate; } /** @@ -274,7 +274,6 @@ export interface PlaylistFull { */ export function instanceOfPlaylistFull(value: object): value is PlaylistFull { let isInstance = true; - isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "id" in value && value["id"] !== undefined; isInstance = isInstance && "isAlbum" in value && value["isAlbum"] !== undefined; isInstance = isInstance && "isImageAutogenerated" in value && value["isImageAutogenerated"] !== undefined; @@ -284,7 +283,7 @@ export function instanceOfPlaylistFull(value: object): value is PlaylistFull { isInstance = isInstance && "favoriteCount" in value && value["favoriteCount"] !== undefined; isInstance = isInstance && "totalPlayCount" in value && value["totalPlayCount"] !== undefined; isInstance = isInstance && "user" in value && value["user"] !== undefined; - isInstance = isInstance && "isStreamGated" in value && value["isStreamGated"] !== undefined; + isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "followeeReposts" in value && value["followeeReposts"] !== undefined; isInstance = isInstance && "followeeFavorites" in value && value["followeeFavorites"] !== undefined; isInstance = isInstance && "hasCurrentUserReposted" in value && value["hasCurrentUserReposted"] !== undefined; @@ -295,6 +294,7 @@ export function instanceOfPlaylistFull(value: object): value is PlaylistFull { isInstance = isInstance && "userId" in value && value["userId"] !== undefined; isInstance = isInstance && "tracks" in value && value["tracks"] !== undefined; isInstance = isInstance && "trackCount" in value && value["trackCount"] !== undefined; + isInstance = isInstance && "isStreamGated" in value && value["isStreamGated"] !== undefined; return isInstance; } @@ -309,7 +309,6 @@ export function PlaylistFullFromJSONTyped(json: any, ignoreDiscriminator: boolea } return { - 'blocknumber': json['blocknumber'], 'artwork': !exists(json, 'artwork') ? undefined : PlaylistArtworkFromJSON(json['artwork']), 'description': !exists(json, 'description') ? undefined : json['description'], 'permalink': !exists(json, 'permalink') ? undefined : json['permalink'], @@ -325,8 +324,7 @@ export function PlaylistFullFromJSONTyped(json: any, ignoreDiscriminator: boolea 'ddexApp': !exists(json, 'ddex_app') ? undefined : json['ddex_app'], 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), 'upc': !exists(json, 'upc') ? undefined : json['upc'], - 'isStreamGated': json['is_stream_gated'], - 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), + 'blocknumber': json['blocknumber'], 'createdAt': !exists(json, 'created_at') ? undefined : json['created_at'], 'followeeReposts': ((json['followee_reposts'] as Array).map(RepostFromJSON)), 'followeeFavorites': ((json['followee_favorites'] as Array).map(FavoriteFromJSON)), @@ -342,6 +340,8 @@ export function PlaylistFullFromJSONTyped(json: any, ignoreDiscriminator: boolea 'coverArtSizes': !exists(json, 'cover_art_sizes') ? undefined : json['cover_art_sizes'], 'coverArtCids': !exists(json, 'cover_art_cids') ? undefined : PlaylistArtworkFromJSON(json['cover_art_cids']), 'trackCount': json['track_count'], + 'isStreamGated': json['is_stream_gated'], + 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), }; } @@ -354,7 +354,6 @@ export function PlaylistFullToJSON(value?: PlaylistFull | null): any { } return { - 'blocknumber': value.blocknumber, 'artwork': PlaylistArtworkToJSON(value.artwork), 'description': value.description, 'permalink': value.permalink, @@ -370,8 +369,7 @@ export function PlaylistFullToJSON(value?: PlaylistFull | null): any { 'ddex_app': value.ddexApp, 'access': AccessToJSON(value.access), 'upc': value.upc, - 'is_stream_gated': value.isStreamGated, - 'stream_conditions': AccessGateToJSON(value.streamConditions), + 'blocknumber': value.blocknumber, 'created_at': value.createdAt, 'followee_reposts': ((value.followeeReposts as Array).map(RepostToJSON)), 'followee_favorites': ((value.followeeFavorites as Array).map(FavoriteToJSON)), @@ -387,6 +385,8 @@ export function PlaylistFullToJSON(value?: PlaylistFull | null): any { 'cover_art_sizes': value.coverArtSizes, 'cover_art_cids': PlaylistArtworkToJSON(value.coverArtCids), 'track_count': value.trackCount, + 'is_stream_gated': value.isStreamGated, + 'stream_conditions': AccessGateToJSON(value.streamConditions), }; } diff --git a/packages/libs/src/sdk/api/generated/full/models/PlaylistFullWithoutTracks.ts b/packages/libs/src/sdk/api/generated/full/models/PlaylistFullWithoutTracks.ts index 59e2ba55744..ce2cc415eff 100644 --- a/packages/libs/src/sdk/api/generated/full/models/PlaylistFullWithoutTracks.ts +++ b/packages/libs/src/sdk/api/generated/full/models/PlaylistFullWithoutTracks.ts @@ -69,12 +69,6 @@ import { * @interface PlaylistFullWithoutTracks */ export interface PlaylistFullWithoutTracks { - /** - * - * @type {number} - * @memberof PlaylistFullWithoutTracks - */ - blocknumber: number; /** * * @type {PlaylistArtwork} @@ -167,16 +161,10 @@ export interface PlaylistFullWithoutTracks { upc?: string; /** * - * @type {boolean} - * @memberof PlaylistFullWithoutTracks - */ - isStreamGated: boolean; - /** - * How to unlock stream access to the track - * @type {AccessGate} + * @type {number} * @memberof PlaylistFullWithoutTracks */ - streamConditions?: AccessGate; + blocknumber: number; /** * * @type {string} @@ -267,6 +255,18 @@ export interface PlaylistFullWithoutTracks { * @memberof PlaylistFullWithoutTracks */ trackCount: number; + /** + * + * @type {boolean} + * @memberof PlaylistFullWithoutTracks + */ + isStreamGated: boolean; + /** + * How to unlock stream access to the track + * @type {AccessGate} + * @memberof PlaylistFullWithoutTracks + */ + streamConditions?: AccessGate; } /** @@ -274,7 +274,6 @@ export interface PlaylistFullWithoutTracks { */ export function instanceOfPlaylistFullWithoutTracks(value: object): value is PlaylistFullWithoutTracks { let isInstance = true; - isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "id" in value && value["id"] !== undefined; isInstance = isInstance && "isAlbum" in value && value["isAlbum"] !== undefined; isInstance = isInstance && "isImageAutogenerated" in value && value["isImageAutogenerated"] !== undefined; @@ -284,7 +283,7 @@ export function instanceOfPlaylistFullWithoutTracks(value: object): value is Pla isInstance = isInstance && "favoriteCount" in value && value["favoriteCount"] !== undefined; isInstance = isInstance && "totalPlayCount" in value && value["totalPlayCount"] !== undefined; isInstance = isInstance && "user" in value && value["user"] !== undefined; - isInstance = isInstance && "isStreamGated" in value && value["isStreamGated"] !== undefined; + isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "followeeReposts" in value && value["followeeReposts"] !== undefined; isInstance = isInstance && "followeeFavorites" in value && value["followeeFavorites"] !== undefined; isInstance = isInstance && "hasCurrentUserReposted" in value && value["hasCurrentUserReposted"] !== undefined; @@ -294,6 +293,7 @@ export function instanceOfPlaylistFullWithoutTracks(value: object): value is Pla isInstance = isInstance && "addedTimestamps" in value && value["addedTimestamps"] !== undefined; isInstance = isInstance && "userId" in value && value["userId"] !== undefined; isInstance = isInstance && "trackCount" in value && value["trackCount"] !== undefined; + isInstance = isInstance && "isStreamGated" in value && value["isStreamGated"] !== undefined; return isInstance; } @@ -308,7 +308,6 @@ export function PlaylistFullWithoutTracksFromJSONTyped(json: any, ignoreDiscrimi } return { - 'blocknumber': json['blocknumber'], 'artwork': !exists(json, 'artwork') ? undefined : PlaylistArtworkFromJSON(json['artwork']), 'description': !exists(json, 'description') ? undefined : json['description'], 'permalink': !exists(json, 'permalink') ? undefined : json['permalink'], @@ -324,8 +323,7 @@ export function PlaylistFullWithoutTracksFromJSONTyped(json: any, ignoreDiscrimi 'ddexApp': !exists(json, 'ddex_app') ? undefined : json['ddex_app'], 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), 'upc': !exists(json, 'upc') ? undefined : json['upc'], - 'isStreamGated': json['is_stream_gated'], - 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), + 'blocknumber': json['blocknumber'], 'createdAt': !exists(json, 'created_at') ? undefined : json['created_at'], 'followeeReposts': ((json['followee_reposts'] as Array).map(RepostFromJSON)), 'followeeFavorites': ((json['followee_favorites'] as Array).map(FavoriteFromJSON)), @@ -341,6 +339,8 @@ export function PlaylistFullWithoutTracksFromJSONTyped(json: any, ignoreDiscrimi 'coverArtSizes': !exists(json, 'cover_art_sizes') ? undefined : json['cover_art_sizes'], 'coverArtCids': !exists(json, 'cover_art_cids') ? undefined : PlaylistArtworkFromJSON(json['cover_art_cids']), 'trackCount': json['track_count'], + 'isStreamGated': json['is_stream_gated'], + 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), }; } @@ -353,7 +353,6 @@ export function PlaylistFullWithoutTracksToJSON(value?: PlaylistFullWithoutTrack } return { - 'blocknumber': value.blocknumber, 'artwork': PlaylistArtworkToJSON(value.artwork), 'description': value.description, 'permalink': value.permalink, @@ -369,8 +368,7 @@ export function PlaylistFullWithoutTracksToJSON(value?: PlaylistFullWithoutTrack 'ddex_app': value.ddexApp, 'access': AccessToJSON(value.access), 'upc': value.upc, - 'is_stream_gated': value.isStreamGated, - 'stream_conditions': AccessGateToJSON(value.streamConditions), + 'blocknumber': value.blocknumber, 'created_at': value.createdAt, 'followee_reposts': ((value.followeeReposts as Array).map(RepostToJSON)), 'followee_favorites': ((value.followeeFavorites as Array).map(FavoriteToJSON)), @@ -386,6 +384,8 @@ export function PlaylistFullWithoutTracksToJSON(value?: PlaylistFullWithoutTrack 'cover_art_sizes': value.coverArtSizes, 'cover_art_cids': PlaylistArtworkToJSON(value.coverArtCids), 'track_count': value.trackCount, + 'is_stream_gated': value.isStreamGated, + 'stream_conditions': AccessGateToJSON(value.streamConditions), }; } diff --git a/packages/libs/src/sdk/api/generated/full/models/TrackFull.ts b/packages/libs/src/sdk/api/generated/full/models/TrackFull.ts index 6386ca2008d..714362a1d49 100644 --- a/packages/libs/src/sdk/api/generated/full/models/TrackFull.ts +++ b/packages/libs/src/sdk/api/generated/full/models/TrackFull.ts @@ -87,24 +87,12 @@ import { * @interface TrackFull */ export interface TrackFull { - /** - * Describes what access the given user has - * @type {Access} - * @memberof TrackFull - */ - access?: Access; /** * * @type {TrackArtwork} * @memberof TrackFull */ artwork?: TrackArtwork; - /** - * The blocknumber this track was last updated - * @type {number} - * @memberof TrackFull - */ - blocknumber: number; /** * * @type {string} @@ -244,29 +232,17 @@ export interface TrackFull { */ playlistsContainingTrack?: Array; /** - * Whether or not the owner has restricted streaming behind an access gate - * @type {boolean} - * @memberof TrackFull - */ - isStreamGated?: boolean; - /** - * How to unlock stream access to the track - * @type {AccessGate} - * @memberof TrackFull - */ - streamConditions?: AccessGate; - /** - * Whether or not the owner has restricted downloading behind an access gate - * @type {boolean} + * Describes what access the given user has + * @type {Access} * @memberof TrackFull */ - isDownloadGated?: boolean; + access?: Access; /** - * How to unlock the track download - * @type {AccessGate} + * The blocknumber this track was last updated + * @type {number} * @memberof TrackFull */ - downloadConditions?: AccessGate; + blocknumber: number; /** * * @type {string} @@ -471,6 +447,30 @@ export interface TrackFull { * @memberof TrackFull */ parentalWarningType?: string; + /** + * Whether or not the owner has restricted streaming behind an access gate + * @type {boolean} + * @memberof TrackFull + */ + isStreamGated?: boolean; + /** + * How to unlock stream access to the track + * @type {AccessGate} + * @memberof TrackFull + */ + streamConditions?: AccessGate; + /** + * Whether or not the owner has restricted downloading behind an access gate + * @type {boolean} + * @memberof TrackFull + */ + isDownloadGated?: boolean; + /** + * How to unlock the track download + * @type {AccessGate} + * @memberof TrackFull + */ + downloadConditions?: AccessGate; } /** @@ -478,7 +478,6 @@ export interface TrackFull { */ export function instanceOfTrackFull(value: object): value is TrackFull { let isInstance = true; - isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "id" in value && value["id"] !== undefined; isInstance = isInstance && "repostCount" in value && value["repostCount"] !== undefined; isInstance = isInstance && "favoriteCount" in value && value["favoriteCount"] !== undefined; @@ -486,6 +485,7 @@ export function instanceOfTrackFull(value: object): value is TrackFull { isInstance = isInstance && "user" in value && value["user"] !== undefined; isInstance = isInstance && "duration" in value && value["duration"] !== undefined; isInstance = isInstance && "playCount" in value && value["playCount"] !== undefined; + isInstance = isInstance && "blocknumber" in value && value["blocknumber"] !== undefined; isInstance = isInstance && "followeeReposts" in value && value["followeeReposts"] !== undefined; isInstance = isInstance && "hasCurrentUserReposted" in value && value["hasCurrentUserReposted"] !== undefined; isInstance = isInstance && "isUnlisted" in value && value["isUnlisted"] !== undefined; @@ -507,9 +507,7 @@ export function TrackFullFromJSONTyped(json: any, ignoreDiscriminator: boolean): } return { - 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), 'artwork': !exists(json, 'artwork') ? undefined : TrackArtworkFromJSON(json['artwork']), - 'blocknumber': json['blocknumber'], 'description': !exists(json, 'description') ? undefined : json['description'], 'genre': !exists(json, 'genre') ? undefined : json['genre'], 'id': json['id'], @@ -533,10 +531,8 @@ export function TrackFullFromJSONTyped(json: any, ignoreDiscriminator: boolean): 'isStreamable': !exists(json, 'is_streamable') ? undefined : json['is_streamable'], 'ddexApp': !exists(json, 'ddex_app') ? undefined : json['ddex_app'], 'playlistsContainingTrack': !exists(json, 'playlists_containing_track') ? undefined : json['playlists_containing_track'], - 'isStreamGated': !exists(json, 'is_stream_gated') ? undefined : json['is_stream_gated'], - 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), - 'isDownloadGated': !exists(json, 'is_download_gated') ? undefined : json['is_download_gated'], - 'downloadConditions': !exists(json, 'download_conditions') ? undefined : AccessGateFromJSON(json['download_conditions']), + 'access': !exists(json, 'access') ? undefined : AccessFromJSON(json['access']), + 'blocknumber': json['blocknumber'], 'createDate': !exists(json, 'create_date') ? undefined : json['create_date'], 'coverArtSizes': !exists(json, 'cover_art_sizes') ? undefined : json['cover_art_sizes'], 'coverArtCids': !exists(json, 'cover_art_cids') ? undefined : CoverArtFromJSON(json['cover_art_cids']), @@ -571,6 +567,10 @@ export function TrackFullFromJSONTyped(json: any, ignoreDiscriminator: boolean): 'copyrightLine': !exists(json, 'copyright_line') ? undefined : json['copyright_line'], 'producerCopyrightLine': !exists(json, 'producer_copyright_line') ? undefined : json['producer_copyright_line'], 'parentalWarningType': !exists(json, 'parental_warning_type') ? undefined : json['parental_warning_type'], + 'isStreamGated': !exists(json, 'is_stream_gated') ? undefined : json['is_stream_gated'], + 'streamConditions': !exists(json, 'stream_conditions') ? undefined : AccessGateFromJSON(json['stream_conditions']), + 'isDownloadGated': !exists(json, 'is_download_gated') ? undefined : json['is_download_gated'], + 'downloadConditions': !exists(json, 'download_conditions') ? undefined : AccessGateFromJSON(json['download_conditions']), }; } @@ -583,9 +583,7 @@ export function TrackFullToJSON(value?: TrackFull | null): any { } return { - 'access': AccessToJSON(value.access), 'artwork': TrackArtworkToJSON(value.artwork), - 'blocknumber': value.blocknumber, 'description': value.description, 'genre': value.genre, 'id': value.id, @@ -609,10 +607,8 @@ export function TrackFullToJSON(value?: TrackFull | null): any { 'is_streamable': value.isStreamable, 'ddex_app': value.ddexApp, 'playlists_containing_track': value.playlistsContainingTrack, - 'is_stream_gated': value.isStreamGated, - 'stream_conditions': AccessGateToJSON(value.streamConditions), - 'is_download_gated': value.isDownloadGated, - 'download_conditions': AccessGateToJSON(value.downloadConditions), + 'access': AccessToJSON(value.access), + 'blocknumber': value.blocknumber, 'create_date': value.createDate, 'cover_art_sizes': value.coverArtSizes, 'cover_art_cids': CoverArtToJSON(value.coverArtCids), @@ -647,6 +643,10 @@ export function TrackFullToJSON(value?: TrackFull | null): any { 'copyright_line': value.copyrightLine, 'producer_copyright_line': value.producerCopyrightLine, 'parental_warning_type': value.parentalWarningType, + 'is_stream_gated': value.isStreamGated, + 'stream_conditions': AccessGateToJSON(value.streamConditions), + 'is_download_gated': value.isDownloadGated, + 'download_conditions': AccessGateToJSON(value.downloadConditions), }; } diff --git a/packages/libs/src/sdk/api/tracks/TracksApi.ts b/packages/libs/src/sdk/api/tracks/TracksApi.ts index 19d7040884a..f857014595b 100644 --- a/packages/libs/src/sdk/api/tracks/TracksApi.ts +++ b/packages/libs/src/sdk/api/tracks/TracksApi.ts @@ -17,13 +17,14 @@ import type { LoggerService } from '../../services/Logger' import type { StorageService } from '../../services/Storage' import { encodeHashId } from '../../utils/hashId' import { parseParams } from '../../utils/parseParams' +import { prepareSplits } from '../../utils/preparePaymentSplits' import { retry3 } from '../../utils/retry' import { Configuration, StreamTrackRequest, TracksApi as GeneratedTracksApi, - UsdcGate, - instanceOfPurchaseGate + ExtendedPaymentSplit, + instanceOfExtendedPurchaseGate } from '../generated/default' import { BASE_PATH, RequiredError } from '../generated/default/runtime' @@ -44,7 +45,9 @@ import { UpdateTrackRequest, UploadTrackRequest, PurchaseTrackRequest, - PurchaseTrackSchema + PurchaseTrackSchema, + GetPurchaseTrackTransactionRequest, + GetPurchaseTrackTransactionSchema } from './types' // Extend that new class @@ -384,26 +387,32 @@ export class TracksApi extends GeneratedTracksApi { } /** - * Purchases stream or download access to a track + * Gets the Solana transaction that purchases the track * * @hidden */ - public async purchase(params: PurchaseTrackRequest) { + public async getPurchaseTrackTransaction( + params: GetPurchaseTrackTransactionRequest + ) { const { userId, trackId, price: priceNumber, extraAmount: extraAmountNumber = 0, - walletAdapter - } = await parseParams('purchase', PurchaseTrackSchema)(params) + wallet + } = await parseParams( + 'getPurchaseTrackTransaction', + GetPurchaseTrackTransactionSchema + )(params) const contentType = 'track' const mint = 'USDC' // Fetch track - this.logger.debug('Fetching track...', { trackId }) - const { data: track } = await this.getTrack({ - trackId: params.trackId // use hashed trackId + this.logger.debug('Fetching track purchase info...', { trackId }) + const { data: track } = await this.getTrackAccessInfo({ + trackId: params.trackId, // use hashed trackId + userId: params.userId // use hashed userId }) // Validate purchase attempt @@ -415,24 +424,24 @@ export class TracksApi extends GeneratedTracksApi { throw new Error('Attempted to purchase free track.') } - if (track.user.id === params.userId) { + if (track.userId === params.userId) { throw new Error('Attempted to purchase own track.') } - let numberSplits: UsdcGate['splits'] = {} + let numberSplits: ExtendedPaymentSplit[] = [] let centPrice: number let accessType: 'stream' | 'download' = 'stream' // Get conditions if ( track.streamConditions && - instanceOfPurchaseGate(track.streamConditions) + instanceOfExtendedPurchaseGate(track.streamConditions) ) { centPrice = track.streamConditions.usdcPurchase.price numberSplits = track.streamConditions.usdcPurchase.splits } else if ( track.downloadConditions && - instanceOfPurchaseGate(track.downloadConditions) + instanceOfExtendedPurchaseGate(track.downloadConditions) ) { centPrice = track.downloadConditions.usdcPurchase.price numberSplits = track.downloadConditions.usdcPurchase.splits @@ -454,40 +463,17 @@ export class TracksApi extends GeneratedTracksApi { throw new Error('Track price increased.') } - let extraAmount = USDC(extraAmountNumber).value + const 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: track.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 splits = await prepareSplits({ + splits: numberSplits, + extraAmount, + claimableTokensClient: this.claimableTokensClient, + logger: this.logger + }) + this.logger.debug('Calculated splits:', splits) const routeInstruction = await this.paymentRouterClient.createRouteInstruction({ @@ -504,26 +490,22 @@ export class TracksApi extends GeneratedTracksApi { accessType }) - if (walletAdapter) { - this.logger.debug('Using connected wallet to purchase...') - if (!walletAdapter.publicKey) { - throw new Error('Could not get connected wallet address') - } + if (wallet) { + this.logger.debug('Using provided wallet to purchase...', { + wallet: wallet.toBase58() + }) // Use the specified Solana wallet const transferInstruction = await this.paymentRouterClient.createTransferInstruction({ - sourceWallet: walletAdapter.publicKey, + sourceWallet: wallet, total, mint }) const transaction = await this.paymentRouterClient.buildTransaction({ - feePayer: walletAdapter.publicKey, + feePayer: wallet, instructions: [transferInstruction, routeInstruction, memoInstruction] }) - return await walletAdapter.sendTransaction( - transaction, - this.paymentRouterClient.connection - ) + return transaction } else { // Use the authed wallet's userbank and relay const ethWallet = await this.auth.getAddress() @@ -560,7 +542,29 @@ export class TracksApi extends GeneratedTracksApi { memoInstruction ] }) - return await this.paymentRouterClient.sendTransaction(transaction) + return transaction + } + } + + /** + * Purchases stream or download access to a track + * + * @hidden + */ + public async purchaseTrack(params: PurchaseTrackRequest) { + await parseParams('purchaseTrack', PurchaseTrackSchema)(params) + const transaction = await this.getPurchaseTrackTransaction(params) + if (params.walletAdapter) { + if (!params.walletAdapter.publicKey) { + throw new Error( + 'Param walletAdapter was specified, but no wallet selected' + ) + } + return await params.walletAdapter.sendTransaction( + transaction, + this.paymentRouterClient.connection + ) } + return this.paymentRouterClient.sendTransaction(transaction) } } diff --git a/packages/libs/src/sdk/api/tracks/types.ts b/packages/libs/src/sdk/api/tracks/types.ts index 20675b94915..debc471f711 100644 --- a/packages/libs/src/sdk/api/tracks/types.ts +++ b/packages/libs/src/sdk/api/tracks/types.ts @@ -1,6 +1,7 @@ import type { WalletAdapter } from '@solana/wallet-adapter-base' import { z } from 'zod' +import { PublicKeySchema } from '../../services/Solana' import { DDEXResourceContributor, DDEXCopyright, @@ -270,27 +271,43 @@ export const UnrepostTrackSchema = z export type UnrepostTrackRequest = z.input +const PurchaseTrackSchemaBase = z.object({ + /** The ID of the user purchasing the track. */ + userId: HashId, + /** The ID of the track to purchase. */ + trackId: HashId, + /** + * The price of the track at the time of purchase (in dollars if number, USDC if bigint). + * Used to check against current track 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() +}) + +export const GetPurchaseTrackTransactionSchema = z + .object({ + /** A wallet to use to purchase (defaults to the authed user's user bank if not specified) */ + wallet: PublicKeySchema.optional() + }) + .merge(PurchaseTrackSchemaBase) + .strict() + +export type GetPurchaseTrackTransactionRequest = z.input< + typeof GetPurchaseTrackTransactionSchema +> + export const PurchaseTrackSchema = z .object({ - /** The ID of the user purchasing the track. */ - userId: HashId, - /** The ID of the track to purchase. */ - trackId: HashId, - /** - * The price of the track at the time of purchase (in dollars if number, USDC if bigint). - * Used to check against current track 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>() .optional() }) + .merge(PurchaseTrackSchemaBase) .strict() export type PurchaseTrackRequest = z.input diff --git a/packages/libs/src/sdk/sdk.ts b/packages/libs/src/sdk/sdk.ts index e1b5eee8693..aa2d4c65be3 100644 --- a/packages/libs/src/sdk/sdk.ts +++ b/packages/libs/src/sdk/sdk.ts @@ -144,7 +144,8 @@ const initializeServices = (config: SdkConfig) => { config.services?.entityManager ?? new EntityManager({ ...getDefaultEntityManagerConfig(servicesConfig), - discoveryNodeSelector + discoveryNodeSelector, + logger }) const storage = diff --git a/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/PaymentRouterClient.ts b/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/PaymentRouterClient.ts index 978820a39b8..e59515172f3 100644 --- a/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/PaymentRouterClient.ts +++ b/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/PaymentRouterClient.ts @@ -109,9 +109,9 @@ export class PaymentRouterClient extends BaseSolanaProgramClient { }) const recipients: PublicKey[] = [] const amounts: bigint[] = [] - for (const [key, value] of Object.entries(args.splits)) { - recipients.push(new PublicKey(key)) - amounts.push(value) + for (const split of args.splits) { + recipients.push(split.wallet) + amounts.push(split.amount) } const totalAmount = mintFixedDecimalMap[args.mint](args.total).value return PaymentRouterProgram.createRouteInstruction({ diff --git a/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/types.ts b/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/types.ts index 69a2f603787..909f94c30a6 100644 --- a/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/types.ts +++ b/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/types.ts @@ -33,7 +33,7 @@ export type CreateTransferInstructionRequest = z.input< export const CreateRouteInstructionSchema = z.object({ mint: MintSchema, - splits: z.record(z.string(), z.bigint()), + splits: z.array(z.object({ wallet: PublicKeySchema, amount: z.bigint() })), total: z.union([z.bigint(), z.number()]) }) diff --git a/packages/libs/src/sdk/utils/preparePaymentSplits.ts b/packages/libs/src/sdk/utils/preparePaymentSplits.ts new file mode 100644 index 00000000000..43bc363839c --- /dev/null +++ b/packages/libs/src/sdk/utils/preparePaymentSplits.ts @@ -0,0 +1,65 @@ +import { USDC } from '@audius/fixed-decimal' + +import { ExtendedPaymentSplit } from '../api/generated/default' +import { LoggerService } from '../services' +import { ClaimableTokensClient } from '../services/Solana/programs/ClaimableTokensClient' + +/** + * 1. Converts amounts to bigints + * 2. Spreads the extraAmount to each split + * 3. Creates user banks for receipients as necessary + * 4. Returns a simplified splits structure, a list of account/amount pairs + */ +export const prepareSplits = async ({ + splits, + extraAmount, + claimableTokensClient, + logger +}: { + splits: ExtendedPaymentSplit[] + extraAmount: bigint + claimableTokensClient: ClaimableTokensClient + logger: LoggerService +}) => { + // Convert splits to big int and spread extra amount to every split + let amountSplits = splits.map((split, index, arr) => { + const amountToAdd = extraAmount / BigInt(arr.length - index) + extraAmount = USDC(extraAmount - amountToAdd).value + return { + ...split, + amount: BigInt(split.amount) + amountToAdd + } + }) + if (extraAmount > 0) { + logger.debug('Calculated splits after extra amount:', amountSplits) + } + + // Check for user banks as needed + amountSplits = await Promise.all( + amountSplits.map(async (split) => { + if (!split.payoutWallet) { + logger.debug('Deriving user bank for user...', { + userId: split.userId + }) + const { userBank, didExist } = + await claimableTokensClient.getOrCreateUserBank({ + ethWallet: split.ethWallet, + mint: 'USDC' + }) + if (!didExist) { + logger.debug('Created user bank', { + userId: split.userId, + userBank: userBank.toBase58() + }) + } + split.payoutWallet = userBank.toBase58() + } + return split + }) + ) + + return amountSplits.map((split) => ({ + wallet: split.payoutWallet, + amount: split.amount + })) +}