Skip to content

Commit

Permalink
feat: clean up dynamic metadata, add asset view page
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Apr 6, 2024
1 parent a7491fe commit bdc399a
Show file tree
Hide file tree
Showing 36 changed files with 563 additions and 51 deletions.
1 change: 1 addition & 0 deletions api-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ type Query {
appConfig: AppConfig!
currencies: [Currency!]!
me: User
metadataAll(account: String!): JSON
solanaGetBalance(account: String!): String
solanaGetTokenAccounts(account: String!): JSON
solanaGetTransactions(account: String!): JSON
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'
import { ApiCoreService } from '@tokengator/api-core-data-access'
import * as fs from 'node:fs/promises'
import { join } from 'node:path'
import { CantFindTheRightTypeScrewItHackathonMode } from './api-metadata.service'

@Injectable()
export class ApiMetadataImageService implements OnModuleInit {
private readonly logger = new Logger(ApiMetadataImageService.name)
private readonly assetPath = join(__dirname, 'assets')
private readonly imagePath = join(this.assetPath, 'images')
private readonly fontPath = join(this.assetPath, 'fonts')
private readonly templatePath = join(this.imagePath, 'templates')

private readonly templateMap = new Map<string, Buffer>()

constructor(private readonly core: ApiCoreService) {}

async onModuleInit(): Promise<void> {
await this.listTemplates()
}

async listTemplates() {
const files = await fs.readdir(this.templatePath)
this.logger.verbose(`Found ${files.length} templates in ${this.templatePath}`)

for (const file of files) {
const buffer = await fs.readFile(join(this.templatePath, file))
this.templateMap.set(file, buffer)
this.logger.verbose(` -> Loaded template: ${file}`)
}
}

generate(accountMetadata: CantFindTheRightTypeScrewItHackathonMode) {
const rand = Math.floor(Math.random() * this.templateMap.size)
const template = Array.from(this.templateMap.values())[rand]
// Do something with the template

return template
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'
import { ApiCoreDataAccessModule } from '@tokengator/api-core-data-access'
import { ApiSolanaDataAccessModule } from '@tokengator/api-solana-data-access'
import { ApiMetadataImageService } from './api-metadata-image.service'
import { ApiMetadataService } from './api-metadata.service'

@Module({
imports: [ApiCoreDataAccessModule, ApiSolanaDataAccessModule],
providers: [ApiMetadataService],
providers: [ApiMetadataService, ApiMetadataImageService],
exports: [ApiMetadataService],
})
export class ApiMetadataDataAccessModule {}
82 changes: 50 additions & 32 deletions libs/api/metadata/data-access/src/lib/api-metadata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PublicKey } from '@solana/web3.js'
import { ApiCoreService } from '@tokengator/api-core-data-access'
import { ApiSolanaService, SolanaAccountInfo } from '@tokengator/api-solana-data-access'
import { LRUCache } from 'lru-cache'
import { ApiMetadataImageService } from './api-metadata-image.service'

export interface CantFindTheRightTypeScrewItHackathonMode {
extension: 'tokenMetadata'
Expand All @@ -18,6 +19,7 @@ export interface CantFindTheRightTypeScrewItHackathonMode {

export interface ExternalMetadata {
image: string
description: string
[key: string]: string
}

Expand All @@ -35,7 +37,6 @@ const TEN_MINUTES = 1000 * 60 * 10
@Injectable()
export class ApiMetadataService {
private readonly logger = new Logger(ApiMetadataService.name)

private readonly externalMetadataCache = new LRUCache<string, ExternalMetadata>({
max: 1000,
ttl: ONE_HOUR,
Expand Down Expand Up @@ -83,8 +84,7 @@ export class ApiMetadataService {
fetchMethod: async (account: string) => {
const image = `${this.core.config.apiUrl}/metadata/image/${account}`
const metadata = await this.accountMetadataCache.fetch(account)

const { name, symbol, description, external_url } = defaults()
const external_url = `${this.core.config.apiUrl}/metadata/redirect/${account}`

if (metadata) {
// The metadata is external, fetch it and return
Expand All @@ -95,62 +95,80 @@ export class ApiMetadataService {
if (!externalMetadata) {
throw new Error(`Failed to fetch external metadata for ${metadata.state.uri}`)
}

if (externalMetadata.image) {
return {
name: metadata.state.name ?? name,
symbol: metadata.state.symbol ?? symbol,
description: metadata.state.uri ?? description,
external_url: metadata.state.uri ?? external_url,
name: metadata.state.name,
symbol: metadata.state.symbol,
image: externalMetadata.image,
description: externalMetadata.description,
external_url,
}
}
}

// The metadata is on the account, return it
return {
name: metadata.state.name ?? name,
symbol: metadata.state.symbol ?? symbol,
description: metadata.state.uri ?? description,
external_url: metadata.state.uri ?? external_url,
name: metadata.state.name,
symbol: metadata.state.symbol,
description: metadata.state.name,
image,
external_url,
attributes: metadata.state.additionalMetadata?.map(([trait_type, value]) => ({
trait_type,
value,
})),
}
}

return {
name,
symbol,
description,
external_url,
image,
}
throw new Error(`Failed to fetch metadata for ${account}`)
},
})

constructor(private readonly core: ApiCoreService, private readonly solana: ApiSolanaService) {}
constructor(
private readonly core: ApiCoreService,
private readonly image: ApiMetadataImageService,
private readonly solana: ApiSolanaService,
) {}

async getImage(account: string): Promise<string> {
async getImage(account: string): Promise<Buffer | string> {
this.logger.verbose(`Fetching metadata image for ${account}`)
const accountMetadata = await this.accountMetadataCache.fetch(account)
if (!accountMetadata) {
throw new Error(`Failed to fetch metadata for ${account}`)
}
const externalMetadata = await this.externalMetadataCache.fetch(accountMetadata.state.uri)
if (!externalMetadata) {
throw new Error(`Failed to fetch external metadata for ${accountMetadata.state.uri}`)

if (!accountMetadata.state.uri?.startsWith(this.core.config.apiUrl)) {
const externalMetadata = await this.externalMetadataCache.fetch(accountMetadata.state.uri)
if (!externalMetadata) {
throw new Error(`Failed to fetch external metadata for ${accountMetadata.state.uri}`)
}

return externalMetadata.image
}

return externalMetadata.image
return this.image.generate(accountMetadata)
}

async getJson(account: string) {
this.logger.verbose(`Fetching metadata json for ${account}`)
return this.jsonCache.fetch(account)
}
}

function defaults() {
const name = `Unknown Name`
const symbol = `Unknown Symbol`
const description = `Unknown Description`
const external_url = `https://example.com`
async getRedirect(account: string) {
return `${this.core.config.webUrl}/assets/${account}`
}

return { name, symbol, description, external_url }
async getAll(account: string) {
const [json, accountMetadata, accountInfo] = await Promise.all([
this.getJson(account),
this.accountMetadataCache.fetch(account),
this.accountCache.fetch(account),
])

return {
json,
accountMetadata,
accountInfo,
}
}
}
20 changes: 18 additions & 2 deletions libs/api/metadata/feature/src/lib/api-metadata.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,29 @@ export class ApiMetadataController {

@Get('image/:account')
async image(@Param('account') account: string, @Res() res: Response) {
const imageUrl = await this.service.getImage(account)
const result = await this.service.getImage(account)

return res.redirect(imageUrl)
if (!result) {
return res.status(404).send('Not found')
}

if (typeof result === 'string') {
return res.redirect(result)
}

res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': result.length })
res.end(result)
}

@Get('json/:account')
async json(@Param('account') account: string) {
return this.service.getJson(account)
}

@Get('redirect/:account')
async redirect(@Param('account') account: string, @Res() res: Response) {
const url = await this.service.getRedirect(account)

res.redirect(url)
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'
import { ApiMetadataDataAccessModule } from '@tokengator/api-metadata-data-access'
import { ApiMetadataController } from './api-metadata.controller'
import { ApiMetadataResolver } from './api-metadata.resolver'

@Module({
controllers: [ApiMetadataController],
imports: [ApiMetadataDataAccessModule],
providers: [ApiMetadataResolver],
})
export class ApiMetadataFeatureModule {}
16 changes: 16 additions & 0 deletions libs/api/metadata/feature/src/lib/api-metadata.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { UseGuards } from '@nestjs/common'
import { Args, Query, Resolver } from '@nestjs/graphql'
import { ApiAuthGraphQLUserGuard } from '@tokengator/api-auth-data-access'
import { ApiMetadataService } from '@tokengator/api-metadata-data-access'
import { GraphQLJSON } from 'graphql-scalars'

@Resolver()
@UseGuards(ApiAuthGraphQLUserGuard)
export class ApiMetadataResolver {
constructor(private readonly service: ApiMetadataService) {}

@Query(() => GraphQLJSON, { nullable: true })
async metadataAll(@Args('account') account: string) {
return this.service.getAll(account)
}
}
20 changes: 8 additions & 12 deletions libs/api/preset/data-access/src/lib/api-preset-minter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export class ApiPresetMinterService {
this.logger.debug(`Program ID: ${this.programId.toString()}`)
}

getMetadataUrl(account: string) {
return `${this.core.config.apiUrl}/metadata/json/${account}`
}

getProgramTokenMinter(provider: AnchorProvider = this.solana.getAnchorProvider()) {
return new Program(TokengatorMinterIDL, this.programId, provider)
}
Expand Down Expand Up @@ -66,7 +70,7 @@ export class ApiPresetMinterService {
applicationConfig: { paymentConfig: appPaymentConfig },
metadataConfig,
},
} = getPresetConfig({ communitySlug, mintPublicKey: mintKeypair.publicKey.toString(), preset })
} = getPresetConfig({ communitySlug, url: this.getMetadataUrl(mintKeypair.publicKey.toString()), preset })

// BELOW HERE WILL MOVE TO THE SDK AT SOME POINT
const [minter] = getMinterPda({ name, mint: mintKeypair.publicKey, programId: this.programId })
Expand Down Expand Up @@ -153,7 +157,7 @@ export class ApiPresetMinterService {
const [manager] = getWNSManagerPda(WEN_NEW_STANDARD_PROGRAM_ID)

const { name, symbol, uri } = {
uri: `https://devnet.tokengator.app/api/metadata/json/${memberMintKeypair.publicKey.toString()}`,
uri: this.getMetadataUrl(memberMintKeypair.publicKey.toString()),
name: 'test',
symbol: 'HI',
}
Expand Down Expand Up @@ -272,15 +276,7 @@ export class ApiPresetMinterService {
}
}

function getPresetConfig({
communitySlug,
mintPublicKey,
preset,
}: {
communitySlug: string
mintPublicKey: string
preset: Preset
}) {
function getPresetConfig({ communitySlug, url, preset }: { communitySlug: string; url: string; preset: Preset }) {
return {
name: preset.name,
description: preset.description ?? '',
Expand All @@ -292,7 +288,7 @@ function getPresetConfig({
}),
minterConfig: {
metadataConfig: {
uri: `https://devnet.tokengator.app/api/metadata/json/${mintPublicKey}.json`,
uri: url,
name: preset.name,
symbol: 'TGC',
metadata: [
Expand Down
Loading

0 comments on commit bdc399a

Please sign in to comment.