From 828967ca3aa578c0c0204c32263cac424ee4515d Mon Sep 17 00:00:00 2001 From: Philip Diaz Date: Wed, 19 May 2021 15:39:40 -0400 Subject: [PATCH 1/4] wip: Support FT contracts for editions --- package.json | 2 +- src/lib/nfts/actions.ts | 67 +++++++++++++++++++++++++++--------- src/lib/nfts/decoders.ts | 21 +++++++++-- src/lib/nfts/queries.ts | 30 ++++++++++++---- src/reducer/async/actions.ts | 10 ++++++ yarn.lock | 8 ++--- 6 files changed, 107 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index cdf113cb..188e4583 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@taquito/taquito": "9.0.0", "@taquito/tzip12": "9.0.0", "@taquito/tzip16": "9.0.0", - "@tqtezos/minter-contracts": "1.2.0", + "@tqtezos/minter-contracts": "1.3.0", "@types/lodash": "4.14.165", "@types/react": "16.9.12", "@types/react-dom": "16.9.0", diff --git a/src/lib/nfts/actions.ts b/src/lib/nfts/actions.ts index 1b634896..ba85d51d 100644 --- a/src/lib/nfts/actions.ts +++ b/src/lib/nfts/actions.ts @@ -1,12 +1,13 @@ -import { MichelsonMap } from '@taquito/taquito'; +import { TezosToolkit, MichelsonMap } from '@taquito/taquito'; import { - Fa2MultiNftAssetCode, + Fa2MultiFtAssetCode, Fa2MultiNftFaucetCode } from '@tqtezos/minter-contracts'; import { Buffer } from 'buffer'; import { SystemWithWallet } from '../system'; import { uploadIPFSJSON } from '../util/ipfs'; import { NftMetadata } from './decoders'; +import { getTokenMetadataBigMap } from './queries'; function toHexString(input: string) { return Buffer.from(input).toString('hex'); @@ -26,7 +27,7 @@ export async function createFaucetContract( metadataMap.set('', toHexString(resp.data.ipfsUri)); return await system.toolkit.wallet .originate({ - code: Fa2MultiNftFaucetCode.code, + code: [Fa2MultiNftFaucetCode.code], storage: { assets: { ledger: new MichelsonMap(), @@ -54,13 +55,14 @@ export async function createAssetContract( metadataMap.set('', toHexString(resp.data.ipfsUri)); return await system.toolkit.wallet .originate({ - code: Fa2MultiNftAssetCode.code, + code: Fa2MultiFtAssetCode.code as any, storage: { assets: { ledger: new MichelsonMap(), next_token_id: 0, operators: new MichelsonMap(), - token_metadata: new MichelsonMap() + token_metadata: new MichelsonMap(), + token_total_supply: new MichelsonMap() }, admin: { admin: system.tzPublicKey, @@ -76,12 +78,12 @@ export async function createAssetContract( export async function mintToken( system: SystemWithWallet, address: string, - metadata: NftMetadata + metadata: NftMetadata, + amount = 1 ) { const contract = await system.toolkit.wallet.at(address); const storage = await contract.storage(); - const token_id = storage.assets.next_token_id; const token_info = new MichelsonMap(); const resp = await uploadIPFSJSON(system.config.ipfsApi, { ...metadata, @@ -90,17 +92,48 @@ export async function mintToken( }); token_info.set('', toHexString(resp.data.ipfsUri)); - return contract.methods - .mint([ - { - owner: system.tzPublicKey, - token_metadata: { - token_id, - token_info + if (contract.methods.mint) { + const token_id = storage.assets.next_token_id; + return contract.methods + .mint([ + { + owner: system.tzPublicKey, + token_metadata: { + token_id, + token_info + } } - } - ]) - .send(); + ]) + .send(); + } + + if (contract.methods.create_token) { + const metadata = await getTokenMetadataBigMap(system.tzkt, address); + const last_id = metadata + .map(row => parseInt(row.key, 10)) + .sort() + .slice(-1)[0]; + const token_id = last_id ? last_id + 1 : 0; + const tz = new TezosToolkit(system.config.rpc); + tz.setWalletProvider(system.wallet); + return tz.wallet + .batch() + .withContractCall(contract.methods.create_token(token_id, token_info)) + .withContractCall( + contract.methods.mint_tokens([ + { + token_id: token_id, + owner: system.tzPublicKey, + amount + } + ]) + ) + .send(); + } + + throw Error( + `Cannot mint: no "mint" or "create_token" method found in ${address}` + ); } export async function transferToken( diff --git a/src/lib/nfts/decoders.ts b/src/lib/nfts/decoders.ts index 5a01db9b..dac59126 100644 --- a/src/lib/nfts/decoders.ts +++ b/src/lib/nfts/decoders.ts @@ -69,9 +69,25 @@ export const AssetMetadataBigMap = t.array( BigMapRow({ key: t.string, value: t.string }) ); +export type NftLedgerBigMap = t.TypeOf; +export const NftLedgerBigMap = t.array( + BigMapRow({ key: t.string, value: t.string }) +); + +export type FtLedgerBigMap = t.TypeOf; +export const FtLedgerBigMap = t.array( + BigMapRow({ + key: t.type({ nat: t.string, address: t.string }), + value: t.string + }) +); + export type LedgerBigMap = t.TypeOf; export const LedgerBigMap = t.array( - BigMapRow({ key: t.string, value: t.string }) + BigMapRow({ + key: t.string, + value: t.type({ owner: t.string, amount: t.string }) + }) ); export type TokenMetadataBigMap = t.TypeOf; @@ -238,7 +254,8 @@ export const Nft = t.intersection([ }), t.partial({ sale: NftSale, - address: t.string + address: t.string, + amount: t.string }) ]); diff --git a/src/lib/nfts/queries.ts b/src/lib/nfts/queries.ts index 9adc56a7..034fb9da 100644 --- a/src/lib/nfts/queries.ts +++ b/src/lib/nfts/queries.ts @@ -24,7 +24,7 @@ async function getAssetMetadataBigMap( ): Promise { const path = 'metadata'; const data = await tzkt.getContractBigMapKeys(address, path); - const decoded = D.LedgerBigMap.decode(data); + const decoded = D.AssetMetadataBigMap.decode(data); if (isLeft(decoded)) { throw Error('Failed to decode `getAssetMetadata` response'); } @@ -37,14 +37,29 @@ async function getLedgerBigMap( ): Promise { const path = 'assets.ledger'; const data = await tzkt.getContractBigMapKeys(address, path); - const decoded = D.LedgerBigMap.decode(data); - if (isLeft(decoded)) { - throw Error('Failed to decode `getLedger` response'); + if (D.NftLedgerBigMap.is(data)) { + return data.map(row => ({ + ...row, + value: { + owner: row.value, + amount: '1' + } + })); } - return decoded.right; + if (D.FtLedgerBigMap.is(data)) { + return data.map(row => ({ + ...row, + key: row.key.nat, + value: { + owner: row.key.address, + amount: row.value + } + })); + } + throw Error('Failed to decode `getLedger` response'); } -async function getTokenMetadataBigMap( +export async function getTokenMetadataBigMap( tzkt: TzKt, address: string ): Promise { @@ -159,7 +174,8 @@ export async function getContractNfts( return { id: parseInt(tokenId, 10), - owner: ledger.find(e => e.key === tokenId)?.value!, + owner: ledger.find(e => e.key === tokenId)?.value.owner!, + amount: ledger.find(e => e.key === tokenId)?.value.amount!, title: metadata.name, description: metadata.description, artifactUri: metadata.artifactUri, diff --git a/src/reducer/async/actions.ts b/src/reducer/async/actions.ts index abb6382a..7057e8d3 100644 --- a/src/reducer/async/actions.ts +++ b/src/reducer/async/actions.ts @@ -61,6 +61,7 @@ export const readFileAsDataUrlAction = createAsyncThunk< try { return await readFile; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.UknownError, message: 'Could not read file' @@ -95,6 +96,7 @@ export const createAssetContractAction = createAsyncThunk< dispatch(getWalletAssetContractsQuery()); return { name, address }; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.CreateAssetContractFailed, message: 'Collection creation failed' @@ -159,6 +161,7 @@ export const mintTokenAction = createAsyncThunk< const blob = await fetched.blob(); file = new File([blob], name, { type }); } catch (e) { + console.log(e); return rejectWithValue({ kind: ErrorKind.UknownError, message: 'Could not mint token: selected file not found' @@ -195,6 +198,7 @@ export const mintTokenAction = createAsyncThunk< const blob = await fetched.blob(); displayFile = new File([blob], name, { type }); } catch (e) { + console.log(e); return rejectWithValue({ kind: ErrorKind.UknownError, message: 'Could not mint token: video display file not found' @@ -225,6 +229,7 @@ export const mintTokenAction = createAsyncThunk< ]; } } catch (e) { + console.log(e); return rejectWithValue({ kind: ErrorKind.IPFSUploadFailed, message: 'IPFS upload failed' @@ -245,6 +250,7 @@ export const mintTokenAction = createAsyncThunk< dispatch(getContractNftsQuery(address)); return { contract: address, metadata }; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.MintTokenFailed, message: 'Mint token failed' @@ -276,6 +282,7 @@ export const transferTokenAction = createAsyncThunk< dispatch(getContractNftsQuery(contract)); return args; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.TransferTokenFailed, message: 'Transfer token failed' @@ -318,6 +325,7 @@ export const listTokenAction = createAsyncThunk< dispatch(getContractNftsQuery(contract)); return args; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.ListTokenFailed, message: 'List token failed' @@ -355,6 +363,7 @@ export const cancelTokenSaleAction = createAsyncThunk< dispatch(getContractNftsQuery(contract)); return { contract: contract, tokenId: tokenId }; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.CancelTokenSaleFailed, message: 'Cancel token sale failed' @@ -400,6 +409,7 @@ export const buyTokenAction = createAsyncThunk< dispatch(getContractNftsQuery(contract)); return { contract: contract, tokenId: tokenId }; } catch (e) { + console.error(e); return rejectWithValue({ kind: ErrorKind.BuyTokenFailed, message: 'Purchase token failed' diff --git a/yarn.lock b/yarn.lock index 428cd8cd..b46018c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2404,10 +2404,10 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-7.1.2.tgz#3a71bb8a45a1e08b71a54c9efcee9927f3895e80" integrity sha512-lDyCVxxgX5lrgCa75ELCfWcdEDyfisjqoDIM3YsghQ+lyViIac/qT67qabQ/HmoVxyikFKovjKwWdn3b/oKhZA== -"@tqtezos/minter-contracts@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@tqtezos/minter-contracts/-/minter-contracts-1.2.0.tgz#5b98addeb6428e7cba27a35475fa601a32df6737" - integrity sha512-KrgdApZnHzTzedUjsNzWxEWYisyeXvd2NIfUj+CcmzL00708EgCbX1zXOo5qLcsRKuffpJGbeARH32rWKUGRAw== +"@tqtezos/minter-contracts@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@tqtezos/minter-contracts/-/minter-contracts-1.3.0.tgz#e89de3fccdb5401bbed3b947f9718508f9ce9407" + integrity sha512-ZxmaBO7s7cxKCK42Fgrv+PbESvjm2HwWqtrnaLqkszdVPy8UIIFIxuSTf9GnzD37lFLJrfv7kpvuVYrwJD3+HQ== "@tsed/logger@5.5.2": version "5.5.2" From d871455c960183766e10758ef0bc93e1ac58bac8 Mon Sep 17 00:00:00 2001 From: Philip Diaz Date: Wed, 19 May 2021 16:25:46 -0400 Subject: [PATCH 2/4] feat: Add amount field to form --- src/components/CreateNonFungiblePage/Form.tsx | 36 ++++++++++---- src/lib/nfts/decoders.ts | 9 +++- src/lib/nfts/queries.ts | 48 ++++++++++++++++++- src/lib/service/tzkt.ts | 16 +++++++ src/reducer/slices/createNft.ts | 18 +++++-- 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/components/CreateNonFungiblePage/Form.tsx b/src/components/CreateNonFungiblePage/Form.tsx index ae894002..eb6f0cd5 100644 --- a/src/components/CreateNonFungiblePage/Form.tsx +++ b/src/components/CreateNonFungiblePage/Form.tsx @@ -17,7 +17,9 @@ import { useSelector, useDispatch } from '../../reducer'; import { addMetadataRow, deleteMetadataRow, - updateField, + updateName, + updateDescription, + updateAmount, updateMetadataRowName, updateMetadataRowValue } from '../../reducer/slices/createNft'; @@ -28,8 +30,12 @@ const DESCRIPTION_PLACEHOLDER = export default function Form() { const state = useSelector(s => s.createNft); + const collections = useSelector(s => s.collections.collections); + const collection = state.collectionAddress + ? collections[state.collectionAddress] || null + : null; const dispatch = useDispatch(); - const { name, description } = state.fields; + const { name, description, amount } = state.fields; return ( <> @@ -42,11 +48,25 @@ export default function Form() { autoFocus={true} placeholder="Input your asset name" value={name || ''} - onChange={e => - dispatch(updateField({ name: 'name', value: e.target.value })) - } + onChange={e => dispatch(updateName(e.target.value))} /> + {collection?.fungible ? ( + + Number of editions + { + const amount = parseInt(e.target.value, 10); + if (!isNaN(amount) && amount > 0 && Number.isInteger(amount)) { + dispatch(updateAmount(amount)); + } + }} + /> + + ) : null} Description @@ -59,11 +79,7 @@ export default function Form() { fontFamily="mono" placeholder={DESCRIPTION_PLACEHOLDER} value={description || ''} - onChange={e => - dispatch( - updateField({ name: 'description', value: e.target.value }) - ) - } + onChange={e => dispatch(updateDescription(e.target.value))} /> diff --git a/src/lib/nfts/decoders.ts b/src/lib/nfts/decoders.ts index dac59126..a8501dda 100644 --- a/src/lib/nfts/decoders.ts +++ b/src/lib/nfts/decoders.ts @@ -269,6 +269,13 @@ export type AssetContract = t.TypeOf; export const AssetContract = t.intersection([ ContractRow(t.unknown), t.type({ - metadata: AssetContractMetadata + metadata: AssetContractMetadata, + fungible: t.boolean }) ]); + +//// Contract Entrypoints + +export const ContractEntrypoints = t.array( + t.type({ name: t.string, jsonParameters: t.unknown, unused: t.boolean }) +); diff --git a/src/lib/nfts/queries.ts b/src/lib/nfts/queries.ts index 034fb9da..7955c080 100644 --- a/src/lib/nfts/queries.ts +++ b/src/lib/nfts/queries.ts @@ -133,6 +133,19 @@ async function getContract( return decoded.right; } +async function getCreateEntrypoint( + tzkt: TzKt, + address: string, + params: Params, + entries: string[] +) { + const entrypoints = await tzkt.getContractEntrypoints(address, params); + if (D.ContractEntrypoints.is(entrypoints)) { + return entrypoints.find(row => entries.includes(row.name))?.name; + } + throw Error('Failed to decode `getCreateEntrypoint` response'); +} + //// Main query functions export async function getContractNfts( @@ -207,7 +220,19 @@ export async function getNftAssetContract( if (isLeft(decoded)) { throw Error('Metadata validation failed'); } - return { ...contract, metadata: decoded.right }; + const createEntry = await getCreateEntrypoint(system.tzkt, address, {}, [ + 'mint', + 'create_token' + ]); + + if (!createEntry) { + throw Error('Could not find `mint` or `create_token` entrypoints'); + } + return { + ...contract, + metadata: decoded.right, + fungible: createEntry === 'create_token' + }; } export async function getWalletNftAssetContracts( @@ -256,6 +281,21 @@ export async function getWalletNftAssetContracts( continue; } try { + const entrypoints = await system.tzkt.getContractEntrypoints( + row.contract.address + ); + if (!D.ContractEntrypoints.is(entrypoints)) { + continue; + } + const createEntry = await getCreateEntrypoint( + system.tzkt, + row.contract.address, + {}, + ['mint', 'create_token'] + ); + if (!createEntry) { + continue; + } const metaUri = row.content.value; const { metadata } = await system.resolveMetadata( fromHexString(metaUri), @@ -263,7 +303,11 @@ export async function getWalletNftAssetContracts( ); const decoded = D.AssetContractMetadata.decode(metadata); if (!isLeft(decoded)) { - results.push({ ...contract, metadata: decoded.right }); + results.push({ + ...contract, + metadata: decoded.right, + fungible: createEntry === 'create_token' + }); } } catch (e) { console.log(e); diff --git a/src/lib/service/tzkt.ts b/src/lib/service/tzkt.ts index 55e1f699..ba9444c0 100644 --- a/src/lib/service/tzkt.ts +++ b/src/lib/service/tzkt.ts @@ -69,6 +69,18 @@ export async function getContractStorage( return response.data; } +export async function getContractEntrypoints( + config: Config, + address: string, + params?: Params +) { + const uri = `${ + config.tzkt.api + }/v1/contracts/${address}/entrypoints?${mkQueryParams(params)}`; + const response = await axios.get(uri); + return response.data; +} + export class TzKt { config: Config; @@ -99,4 +111,8 @@ export class TzKt { getContractStorage(address: string, params?: Params) { return getContractStorage(this.config, address, params); } + + getContractEntrypoints(address: string, params?: Params) { + return getContractEntrypoints(this.config, address, params); + } } diff --git a/src/reducer/slices/createNft.ts b/src/reducer/slices/createNft.ts index ba3ac6fe..8f0b83e5 100644 --- a/src/reducer/slices/createNft.ts +++ b/src/reducer/slices/createNft.ts @@ -11,6 +11,7 @@ export const steps: Step[] = ['file_upload', 'asset_details', 'confirm']; interface Fields { name: string | null; description: string | null; + amount: number; } export enum CreateStatus { @@ -49,7 +50,8 @@ export const initialState: CreateNftState = { uploadedArtifact: null, fields: { name: null, - description: null + description: null, + amount: 1 }, attributes: [], collectionAddress: null, @@ -80,8 +82,14 @@ const slice = createSlice({ state.step = steps[stepIdx - 1]; } }, - updateField(state, action: UpdateFieldAction) { - state.fields[action.payload.name] = action.payload.value; + updateName(state, action: PayloadAction) { + state.fields.name = action.payload; + }, + updateDescription(state, action: PayloadAction) { + state.fields.description = action.payload; + }, + updateAmount(state, action: PayloadAction) { + state.fields.amount = action.payload; }, updateSelectedFile(state, action: PayloadAction) { state.selectedFile = action.payload; @@ -133,7 +141,9 @@ const slice = createSlice({ export const { incrementStep, decrementStep, - updateField, + updateName, + updateDescription, + updateAmount, updateSelectedFile, clearSelectedfile, updateDisplayImageFile, From 03118f0d06b0fd9799580964aff5f78b131a24e3 Mon Sep 17 00:00:00 2001 From: Philip Diaz Date: Wed, 19 May 2021 16:30:04 -0400 Subject: [PATCH 3/4] feat: Display token amount in token view --- .../Collections/TokenDetail/index.tsx | 183 +++++++++++++++--- 1 file changed, 160 insertions(+), 23 deletions(-) diff --git a/src/components/Collections/TokenDetail/index.tsx b/src/components/Collections/TokenDetail/index.tsx index 95ef413f..161af931 100644 --- a/src/components/Collections/TokenDetail/index.tsx +++ b/src/components/Collections/TokenDetail/index.tsx @@ -30,8 +30,8 @@ import { getNftAssetContractQuery } from '../../../reducer/async/queries'; import { TokenMedia } from '../../common/TokenMedia'; -import lk from '../../common/assets/link-icon.svg' -import tz from '../../common/assets/tezos-sym.svg' +import lk from '../../common/assets/link-icon.svg'; +import tz from '../../common/assets/tezos-sym.svg'; import { Maximize2 } from 'react-feather'; function NotFound() { @@ -168,8 +168,8 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { pt={[10, 0]} pb={[5, 0]} width={['100%']} - maxHeight={["30vh", "60vh", "70vh"]} - height={["100%"]} + maxHeight={['30vh', '60vh', '70vh']} + height={['100%']} justifyContent="center" > - + - - + - + {token.title} Minter: - - {token?.metadata?.minter}  + + + {token?.metadata?.minter}  + + + + Collection: - - {state.selectedCollection - ? state.collections[state.selectedCollection]?.metadata.name : collection?.metadata.name ? collection?.metadata.name : contractAddress }  + + + {state.selectedCollection + ? state.collections[state.selectedCollection] + ?.metadata.name + : collection?.metadata.name + ? collection?.metadata.name + : contractAddress} +   + + + + + + + + Amount: + + {token?.amount || 1} {token?.metadata?.attributes?.map(({ name, value }) => ( {name}: - + {value} @@ -250,13 +343,38 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { - - + + {token.sale ? ( isOwner ? ( <> - - {token.sale.price} + + {token.sale.price}{' '} + ) : ( <> - - {token.sale.price.toFixed(2)} + + {token.sale.price.toFixed(2)}{' '} + - + ) ) : isOwner ? ( - + ) : ( <> @@ -319,7 +456,7 @@ function TokenDetail({ contractAddress, tokenId }: TokenDetailProps) { - + ); } From 3fa44f2c7eafdf9cad79552c0ad3383088074c49 Mon Sep 17 00:00:00 2001 From: Philip Diaz Date: Thu, 20 May 2021 12:09:01 -0400 Subject: [PATCH 4/4] fix: Remove unused type --- src/reducer/slices/createNft.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/reducer/slices/createNft.ts b/src/reducer/slices/createNft.ts index 8f0b83e5..e0e4c47b 100644 --- a/src/reducer/slices/createNft.ts +++ b/src/reducer/slices/createNft.ts @@ -62,7 +62,6 @@ export const initialState: CreateNftState = { // Reducers & Slice -type UpdateFieldAction = PayloadAction<{ name: keyof Fields; value: string }>; type UpdateRowNameAction = PayloadAction<{ key: number; name: string }>; type UpdateRowValueAction = PayloadAction<{ key: number; value: string }>;