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

Mint badge #8

Merged
merged 13 commits into from
Sep 7, 2023
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thebadge/sdk",
"version": "0.0.2",
"version": "0.1.0",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.es.js",
Expand Down
16 changes: 16 additions & 0 deletions src/business-logic/theBadge/BadgeMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BackendFileResponse, BackendFileUpload, IPFSHash, NFTAttribute } from '
export type BadgeEvidenceMetadata = {
columns: MetadataColumn[]
values: Record<string, unknown>
submittedAt: number
}

export type BadgeModelMetadata<T = IPFSHash | BackendFileResponse | BackendFileUpload> = {
Expand All @@ -22,3 +23,18 @@ export type BadgeMetadata<T = IPFSHash | BackendFileResponse | BackendFileUpload
image: T
attributes?: NFTAttribute[]
}

export enum BadgeNFTAttributesType {
Background = 'Background',
TextContrast = 'TextContrast',
}

export type EvidenceMetadata = {
title: string
description: string
// Attached file evidence
fileURI?: string
fileTypeExtension?: string
// File Mimetype
type?: string
}
117 changes: 116 additions & 1 deletion src/services/badges/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ import {
} from '@subgraph/generated/subgraph'
import { TheBadgeSDKConfig } from '@businessLogic/sdk/config'
import { getFromIPFS } from '@utils/ipfs'
import { MetadataColumn } from '@businessLogic/kleros/types'
import { contracts } from '../../contracts/contracts'
import { TheBadge__factory } from '@subgraph/generated/typechain'
import {
createAndUploadBadgeEvidence,
createAndUploadBadgeMetadata,
createEvidencesValuesObject,
encodeIpfsEvidence,
} from '@utils/badges/mintHelpers'
import { BadgeModelMetadata } from '@businessLogic/theBadge/BadgeMetadata'
import { KlerosListStructure } from '@utils/kleros/generateKlerosListMetaEvidence'
import { BackendFileResponse } from '@businessLogic/types'
import { ContractTransaction } from 'ethers'
import { schemaFactory } from '@utils/zod/validators'

interface BadgesServiceMethods {
get(searchParams?: { first: number; skip: number; filter?: Badge_Filter }): Promise<BadgesQuery>
Expand All @@ -31,7 +45,13 @@ interface BadgesServiceMethods {
getMetadataOfBadge(badgeId: string): Promise<BadgeMetadataByIdQuery>
getMetadataOfBadgesUserHasChallenged(userAddress: string): Promise<BadgesMetadataUserHasChallengedQuery>
getImageIPFSHashOfBadge(badgeId: string): Promise<string>
// mint(userAddress: string, badgeModelId: string, evidences: List<Evidence>) TODO coming soon
mint(
userAddress: string,
badgeModelId: string,
evidences: Record<string, unknown>,
base64PreviewImage: string,
): Promise<ContractTransaction>
// claim(userAddress: string, badgeId: string) TODO coming soon
// challenge(userAddress: string, badgeId: string, evidences?: List<Evidence>) TODO coming soon
}

Expand Down Expand Up @@ -191,4 +211,99 @@ export class BadgesService extends TheBadgeSDKConfig implements BadgesServiceMet
// return badge image ipfs hash
return badgeImageIPFSHash
}

/**
* Mint a new badge of a certain model
*
* @param userAddress
* @param badgeModelId
* @param evidences is an object with { evidenceIndex: evidenceValue }, example: { 0: 'text1', 1: 'text2', 2: date1 }
* @param base64PreviewImage
*/
public async mint(
userAddress: string,
badgeModelId: string,
evidences: Record<number, unknown>,
base64PreviewImage: string,
): Promise<ContractTransaction> {
if (!this.web3Provider) {
throw new Error('You need to initialize a web3Provider to perform this transaction')
}

// connect contract with selected chainId and signer
const tbContract = TheBadge__factory.connect(
contracts.TheBadge.address[this.chainId],
this.web3Provider.getSigner(userAddress),
)

// get badge model
const badgeModelResponse = await this.subgraph.badgeModelById({ id: badgeModelId })
const badgeModel = badgeModelResponse.badgeModel

if (!badgeModel?.uri) {
throw new Error('No badge model uri, please enter a valid badge model id')
}

// get ipfs data of badge model
const { result: badgeModelIPFSDataResult, error: badgeModelIPFSDataError } = await getFromIPFS<
BadgeModelMetadata<BackendFileResponse>
>(badgeModel?.uri)
if (badgeModelIPFSDataError) {
throw new Error('Error obtaining IPFS data of badge model')
}
const badgeModelIPFSData = badgeModelIPFSDataResult?.content

// get badge model metadata
const badgeModelMetadataResponse = await this.subgraph.badgeModelMetadataById({ id: badgeModelId })
const badgeModelMetadata = badgeModelMetadataResponse.badgeModelKlerosMetaData

const registrationUri = badgeModelMetadata?.registrationUri
if (!registrationUri) {
throw new Error('No badge model metadata registration uri, please enter a valid badge model id')
}
const { result: registrationUriResult, error: registrationUriError } = await getFromIPFS<KlerosListStructure>(
registrationUri,
)
if (registrationUriError) {
throw new Error('Could not obtain registration data from IPFS, please enter a valid model id')
}

const requiredEvidencesList = registrationUriResult?.content?.metadata?.columns as MetadataColumn[]

// check if evidences fulfill the evidence requirements of the badge model
const schema = schemaFactory(requiredEvidencesList)
// try {
const evidencesParsedObject = schema.parse(evidences)
// } catch (e) {
// throw e // TODO add custom error parsing zod error
// }
if (!evidencesParsedObject) {
throw new Error('Wrong evidences sent, please check the required evidences list')
}

// upload evidence to IPFS
const evidencesValues = createEvidencesValuesObject(evidencesParsedObject, requiredEvidencesList)
const evidenceIPFSHash = await createAndUploadBadgeEvidence(requiredEvidencesList, evidencesValues)
if (!evidenceIPFSHash) {
throw new Error('No evidence IPFS hash, could not upload evidence to IPFS')
}

// encode data for kleros minting
const klerosControllerDataEncoded = encodeIpfsEvidence(evidenceIPFSHash)

// obtain price of mint
const mintValue = await tbContract.mintValue(badgeModelId)

// create metadata of next minted badge and upload to IPFS
const badgeMetadataIPFSHash = await createAndUploadBadgeMetadata(
badgeModelIPFSData as BadgeModelMetadata,
userAddress,
{ imageBase64File: base64PreviewImage },
)

// mint badge
return tbContract.mint(badgeModelId, userAddress, badgeMetadataIPFSHash, klerosControllerDataEncoded, {
value: mintValue,
})
}
}
79 changes: 79 additions & 0 deletions src/utils/badges/mintHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { convertHashToValidIPFSKlerosHash, uploadToIPFS } from '@utils/ipfs'
import { THE_BADGE_DAPP_URL } from '@utils/constants'
import { BadgeEvidenceMetadata, BadgeMetadata, BadgeModelMetadata } from '@businessLogic/theBadge/BadgeMetadata'
import { BackendFileUpload } from '@businessLogic/types'
import { MetadataColumn } from '@businessLogic/kleros/types'
import { defaultAbiCoder } from 'ethers/lib/utils'

export async function createAndUploadBadgeMetadata(
badgeModelMetadata: BadgeModelMetadata,
minterAddress: string,
additionalArgs: {
imageBase64File: string
},
) {
const badgeMetadataIPFSUploaded = await uploadToIPFS<BadgeMetadata<BackendFileUpload>>({
attributes: {
name: badgeModelMetadata?.name || '',
description: badgeModelMetadata?.description || '',
external_link: `${THE_BADGE_DAPP_URL}/profile/${minterAddress}`,
attributes: [],
image: { mimeType: 'image/png', base64File: additionalArgs.imageBase64File },
},
filePaths: ['image'],
})

return `ipfs://${badgeMetadataIPFSUploaded.result?.ipfsHash}`
}

export async function createAndUploadBadgeEvidence(
columns: MetadataColumn[],
values: Record<string, unknown>,
): Promise<string> {
const filePaths = getFilePathsFromValues(values)
const evidenceIPFSUploaded = await uploadToIPFS<BadgeEvidenceMetadata>({
attributes: {
columns,
values,
submittedAt: Date.now(),
},
filePaths: filePaths,
})

const ipfsHash = evidenceIPFSUploaded.result?.ipfsHash
if (!ipfsHash) {
throw new Error('Evidence could not be uploaded to IPFS.')
}

return convertHashToValidIPFSKlerosHash(ipfsHash)
}

export function createEvidencesValuesObject(
data: Record<string, unknown>,
metadataColumns?: MetadataColumn[] | null, // needed evidences list
): Record<string, unknown> {
const values: Record<string, unknown> = {}
if (!metadataColumns) return values
metadataColumns.forEach((column, i) => {
values[`${column.label}`] = data[`${i}`]
})
return values
}

function getFilePathsFromValues(values: Record<string, unknown>) {
if (!values) return []
const filePaths: string[] = []
Object.keys(values).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (values[key]?.base64File) {
filePaths.push(`values.${key}`)
}
})

return filePaths
}

export const encodeIpfsEvidence = (ipfsEvidenceHash: string): string => {
return defaultAbiCoder.encode([`tuple(string)`], [[ipfsEvidenceHash]])
}
1 change: 1 addition & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const THE_BADGE_BACKEND_URL = 'https://api.thebadge.xyz/staging'
export const THE_BADGE_THE_GRAPH_BASE_URL = 'https://api.thegraph.com/subgraphs/name/thebadgeadmin/'
export const THE_BADGE_DAPP_URL = 'https://staging-app.thebadge.xyz'
13 changes: 9 additions & 4 deletions src/utils/ipfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ export async function getFromIPFS<T, X = {}>(hash: string): Promise<BackendRespo
return response.data
}

type Args<T = Record<string, unknown>> = {
attributes: T
filePaths?: string[]
needKlerosPath?: boolean
}

/**
* Uploads data to IPFS, using The Badge's backend.
* @async
* @param {attributes: Record<string, unknown>, filePaths?: string[]} metadata
*/
export async function uploadToIPFS(metadata: {
attributes: Record<string, unknown>
filePaths?: string[]
}): Promise<BackendResponse<{ ipfsHash: string; s3Url: string }>> {
export async function uploadToIPFS<T>(
metadata: Args<T>,
): Promise<BackendResponse<{ ipfsHash: string; s3Url: string }>> {
const res = await axios.post<BackendResponse<{ ipfsHash: string; s3Url: string }>>(
`${THE_BADGE_BACKEND_URL}/api/ipfs/pin`,
metadata,
Expand Down
Loading
Loading