Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

nft token trade history #754

Merged
merged 4 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"**/node_modules": true,
"pnpm-lock.yaml": true,
".esbuild": true,
".eslintignore": true
".eslintignore": true,
"_": true
},
"window.title": "🦙 ${rootName} ${separator} ${activeEditorMedium} 🦙",
// git
Expand Down
13 changes: 13 additions & 0 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ functions:
timeout: 29
memorySize: 1024

getNFTsHistory:
handler: src/handlers/getNFTsHistory.handler
description: Get nfts activity history
events:
- httpApi:
method: post
path: /nfts/history/{address?}
- httpApi:
method: get
path: /nfts/history/{address}
timeout: 29
memorySize: 1024

getAdapters:
handler: src/handlers/getAdapters.handler
description: Get adapters
Expand Down
49 changes: 36 additions & 13 deletions src/handlers/getNFTs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { defillamaCollections, fetchNFTMetadataFrom, fetchUserNFTCollectionsFrom
import type { NftScanMetadata as NFTMetadata, UserNFTCollection } from '@lib/nft/nft-scan'
import { fetchTokenPrices } from '@lib/price'
import { isFulfilled } from '@lib/promise'
import type { AwaitedReturnType } from '@lib/type'
import type { APIGatewayProxyHandler } from 'aws-lambda'
import type { Address } from 'viem'
import { isAddress } from 'viem'
Expand All @@ -29,7 +30,6 @@ interface UserNFTsResponse {
}

export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false
try {
const address = event.pathParameters?.address
if (!address) {
Expand Down Expand Up @@ -63,11 +63,17 @@ async function tokensPrice(ids: Array<`${Chain}:${Address}`>) {
// TODO: add rate limit checker
const rateLimitReached = true

export async function getUserNFTTokens(address: Address) {
export async function getUserNFTs(address: Address) {
if (rateLimitReached) {
const userNFTsResponse = await paginatedFetch({
fn: fetchUserNFTsFrom.alchemy<false>,
initialParams: { address, spamConfidenceLevel: 'LOW', withMetadata: false, pageSize: 100 },
initialParams: {
address,
pageSize: 100,
chain: 'ethereum',
withMetadata: false,
spamConfidenceLevel: 'LOW',
},
iterations: 10,
pageKeyProp: 'pageKey',
})
Expand All @@ -93,13 +99,17 @@ export async function getUserNFTTokens(address: Address) {
.sort((a, b) => a.id.localeCompare(b.id))
}

export async function nftsHandler({ address }: { address: Address }): Promise<UserNFTsResponse> {
const { price: ethPrice } = await tokensPrice([`ethereum:${ADDRESS_ZERO}`])

const userNFTs = await getUserNFTTokens(address)

const chunks = sliceIntoChunks(userNFTs, 50)

export async function getNFTsMetadata({
nfts,
maxBatchSize = 50,
}: {
nfts: Array<{
address: string
tokenID: string
}>
maxBatchSize?: number
}) {
const chunks = sliceIntoChunks(nfts, maxBatchSize)
const metadataPromiseResult = await Promise.allSettled(
chunks.map((chunk) =>
fetchNFTMetadataFrom.nftScan({
Expand All @@ -110,7 +120,7 @@ export async function nftsHandler({ address }: { address: Address }): Promise<Us
)
const metadataFulfilledResults = (
metadataPromiseResult.filter((result) => isFulfilled(result)) as PromiseFulfilledResult<
Awaited<ReturnType<typeof fetchNFTMetadataFrom.nftScan>>
AwaitedReturnType<typeof fetchNFTMetadataFrom.nftScan>
>[]
).flatMap((item) => item.value.data)

Expand All @@ -122,8 +132,21 @@ export async function nftsHandler({ address }: { address: Address }): Promise<Us
}))
.sort((a, b) => a.id.localeCompare(b.id))

return flattenedMetadata
}

export async function nftsHandler({ address }: { address: Address }): Promise<UserNFTsResponse> {
const { price: ethPrice } = await tokensPrice([`ethereum:${ADDRESS_ZERO}`])

const userNFTs = await getUserNFTs(address)

const nftsMetadata = await getNFTsMetadata({
nfts: userNFTs.map((nft) => ({ address: nft.address, tokenID: nft.tokenID })),
})

const mergedNFTs = userNFTs.map((nft, index) => {
const metadata = flattenedMetadata[index]
const metadata = nftsMetadata[index]

return { ...nft, ...metadata }
})

Expand All @@ -149,7 +172,7 @@ export async function nftsHandler({ address }: { address: Address }): Promise<Us

const collections = (
collectionsPromiseResult.filter((result) => isFulfilled(result)) as PromiseFulfilledResult<
Awaited<ReturnType<typeof fetchUserNFTCollectionsFrom.nftScan>>
AwaitedReturnType<typeof fetchUserNFTCollectionsFrom.nftScan>
>[]
).flatMap((item) => item.value.data)

Expand Down
127 changes: 127 additions & 0 deletions src/handlers/getNFTsHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { badRequest, serverError, success } from '@handlers/response'
import { sliceIntoChunks } from '@lib/array'
import { fetchNFTTradingHistoryFrom, fetchUserNFTActivityFrom } from '@lib/nft'
import type { QuickNodeChain, QuickNodeTokenEvent } from '@lib/nft/quick-node'
import type { NFTActivity } from '@lib/nft/reservoir'
import type { APIGatewayProxyHandler } from 'aws-lambda'
import { isAddress } from 'viem'

interface NFTTokenActivity {
type: string
marketplace?: string
fromAddress: string
toAddress?: string
amount?: number
blockNumber?: number
timestamp: string
contractAddress?: string
tokenId?: string | number
transactionHash?: string
}

async function history(address: string, body: string | null) {
try {
// try reservoir first, if it fails, try quicknode
const userNFTActivity = await fetchUserNFTActivityFrom.reservoir({ users: [address], includeMetadata: false })
return formatNFTReservoirTokenEvents(userNFTActivity.activities)
} catch (error) {
const payload = JSON.parse(body ?? '[{}]') as Array<{
contractAddress: string
tokenId: string
chain: QuickNodeChain
}>
if (!Array.isArray(payload)) return badRequest('Invalid body parameter, expected array')
const chunks = sliceIntoChunks(payload, 100)
const promisesResponse = await Promise.all(chunks.map((chunk) => fetchNFTTradingHistoryFrom.quickNode(chunk)))
const response = promisesResponse.flat()

return formatNFTQuickNodeTokenEvents(
response
.map((events) => {
const [[, { nft }]] = Object.entries(events)
return nft.QuickNodeTokenEvents.edges.map((edge) => edge.node)
})
.flat(),
)
}
}

/**
* Takes array of nft tokens (contractAddress, tokenId, chain) and returns trading history for each token
*/
export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false
try {
const address = event.pathParameters?.address
if (!address) {
return badRequest('Missing address parameter')
}
if (!isAddress(address)) {
return badRequest('Invalid address parameter, expected hex')
}

const response = await history(address, event.body)

return success(response, { maxAge: 30 * 60 })
} catch (error) {
console.error('Failed to fetch NFTs trading history', { error })
return serverError('Failed to fetch NFTs trading history')
} finally {
console.log('NFTs trading history request took', context.getRemainingTimeInMillis() / 1000, 'seconds')
}
}

export function formatNFTQuickNodeTokenEvents(tokenEvents: Array<QuickNodeTokenEvent>): Array<NFTTokenActivity> {
return tokenEvents.map((event) => {
const {
type,
fromAddress,
toAddress,
timestamp,
transactionHash,
blockNumber,
marketplace,
contractAddress,
tokenId,
receivedTokenId,
sentTokenId,
tokenQuantity,
sentTokenQuantity,
} = event
return {
type,
marketplace,
fromAddress,
toAddress,
amount: tokenQuantity ?? sentTokenQuantity,
blockNumber,
timestamp,
contractAddress,
tokenId: tokenId ?? sentTokenId ?? receivedTokenId,
transactionHash,
}
})
}

export function formatNFTReservoirTokenEvents(activities: Array<NFTActivity>): Array<NFTTokenActivity> {
return activities.map((activity) => {
const {
type,
fromAddress,
toAddress,
timestamp,
txHash,
contract,
token: { tokenId },
} = activity
return {
type,
fromAddress,
toAddress,
timestamp: new Date(timestamp).toISOString(),
contractAddress: contract,
tokenId,
transactionHash: txHash,
}
})
}
15 changes: 15 additions & 0 deletions src/lib/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { raise } from '@lib/error'
import type { Json } from '@lib/type'

/** this function fetches the schema of any public GraphQL endpoint */
export async function fetchGraphQLSchema(url: string): Promise<Json> {
const response = await fetcher<Json>(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query:
'query IntrospectionQuery { __schema { queryType { name } mutationType { name } subscriptionType { name } types { ...FullType } directives { name description locations args { ...InputValue } } } } fragment FullType on __Type { kind name description fields(includeDeprecated: true) { name description args { ...InputValue } type { ...TypeRef } isDeprecated deprecationReason } inputFields { ...InputValue } interfaces { ...TypeRef } enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason } possibleTypes { ...TypeRef } } fragment InputValue on __InputValue { name description type { ...TypeRef } defaultValue } fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } } }',
variables: {},
}),
})
return response
}

export async function fetcher<T>(url: string, options?: RequestInit) {
const response = await fetch(url, {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/nft/class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Chain } from '@lib/chains'
import type { Address } from 'viem'

export abstract class UserNFTs {
constructor(
public readonly walletAddress: Address,
public readonly chain: Chain,
) {}

abstract fetch(): Promise<void>
}
11 changes: 11 additions & 0 deletions src/lib/nft/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import {
fetchUserNFTCollections as fetchUserNFTCollectionsFromNftScan,
fetchUserNFTs as fetchUserNFTsFromNftScan,
} from './nft-scan'
import {
batchFetchMetadataFromQuickNode,
batchFetchNFTTradingHistoryFromQuickNode,
fetchUserNFTsFromQuickNode,
} from './quick-node'
import {
fetchUserNFTCollections as fetchUserNFTCollectionsFromReservoir,
fetchUsersNFTActivity as fetchUsersNFTActivityFromReservoir,
Expand All @@ -31,12 +36,14 @@ export const fetchUserNFTsFrom = {
nftScan: fetchUserNFTsFromNftScan,
sequence: fetchUserNFTsFromSequence,
center: fetchUserNFTsFromCenter,
quickNode: fetchUserNFTsFromQuickNode,
}

export const fetchNFTMetadataFrom = {
center: batchFetchMetadataFromCenter,
alchemy: batchFetchMetadataFromAlchemy,
nftScan: batchFetchMetadataFromNftScan,
quickNode: batchFetchMetadataFromQuickNode,
}

export const fetchUserNFTCollectionsFrom = {
Expand All @@ -47,3 +54,7 @@ export const fetchUserNFTCollectionsFrom = {
export const fetchUserNFTActivityFrom = {
reservoir: fetchUsersNFTActivityFromReservoir,
}

export const fetchNFTTradingHistoryFrom = {
quickNode: batchFetchNFTTradingHistoryFromQuickNode,
}
Loading