diff --git a/.changeset/light-boats-relate.md b/.changeset/light-boats-relate.md new file mode 100644 index 00000000..afa013ec --- /dev/null +++ b/.changeset/light-boats-relate.md @@ -0,0 +1,5 @@ +--- +'freshmint': minor +--- + +Show claim transaction status in web UI diff --git a/.changeset/sixty-kings-vanish.md b/.changeset/sixty-kings-vanish.md new file mode 100644 index 00000000..fab355e4 --- /dev/null +++ b/.changeset/sixty-kings-vanish.md @@ -0,0 +1,5 @@ +--- +'@freshmint/cadence-loader': minor +--- + +Inject environment variables into cadence-loader diff --git a/.changeset/three-peas-live.md b/.changeset/three-peas-live.md new file mode 100644 index 00000000..e5bde2e4 --- /dev/null +++ b/.changeset/three-peas-live.md @@ -0,0 +1,5 @@ +--- +'@freshmint/react': minor +--- + +Return transaction status from useTransaction diff --git a/package-lock.json b/package-lock.json index 9c7b6dc9..cbb801bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ ], "devDependencies": { "@changesets/cli": "^2.25.2", + "@types/jest": "^28.1.6", + "@types/node": "^18.11.13", "@typescript-eslint/eslint-plugin": "^5.39.0", "eslint": "^8.24.0", "prettier": "latest", @@ -2848,13 +2850,6 @@ "@sinonjs/commons": "^1.7.0" } }, - "node_modules/@tuplo/envsubst": { - "version": "1.15.0", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/@types/babel__core": { "version": "7.1.19", "dev": true, @@ -2996,8 +2991,9 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==" }, "node_modules/@types/node": { - "version": "18.8.2", - "license": "MIT" + "version": "18.11.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.15.tgz", + "integrity": "sha512-VkhBbVo2+2oozlkdHXLrb3zjsRkpdnaU2bXmX8Wgle3PUi569eLRaHGlgETQHR7lLL1w7GiG3h9SnePhxNDecw==" }, "node_modules/@types/node-fetch": { "version": "2.6.2", @@ -14076,7 +14072,6 @@ "dependencies": { "@onflow/fcl": "^1.2.0", "@onflow/flow-cadut": "^0.2.0-alpha.9", - "@tuplo/envsubst": "^1.15.0", "react": "^18.2.0", "schema-utils": "^4.0.0" }, @@ -14090,7 +14085,7 @@ }, "packages/core": { "name": "@freshmint/core", - "version": "0.4.0", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { "@onflow/fcl": "^1.2.0", @@ -14114,10 +14109,10 @@ } }, "packages/freshmint": { - "version": "0.2.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@freshmint/core": "^0.4.0", + "@freshmint/core": "^0.7.0", "@ipld/car": "^4.1.6", "@onflow/decode": "0.0.11", "@onflow/flow-cadut": "^0.2.0-alpha.9", @@ -15475,11 +15470,10 @@ "requires": { "@onflow/fcl": "^1.2.0", "@onflow/flow-cadut": "^0.2.0-alpha.9", - "@tuplo/envsubst": "^1.15.0", "@types/react": "^18.0.21", "react": "^18.2.0", "schema-utils": "^4.0.0", - "tsup": "6.5.0" + "tsup": "^6.5.0" } }, "@freshmint/core": { @@ -16422,9 +16416,6 @@ "@sinonjs/commons": "^1.7.0" } }, - "@tuplo/envsubst": { - "version": "1.15.0" - }, "@types/babel__core": { "version": "7.1.19", "dev": true, @@ -16551,7 +16542,9 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==" }, "@types/node": { - "version": "18.8.2" + "version": "18.11.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.15.tgz", + "integrity": "sha512-VkhBbVo2+2oozlkdHXLrb3zjsRkpdnaU2bXmX8Wgle3PUi569eLRaHGlgETQHR7lLL1w7GiG3h9SnePhxNDecw==" }, "@types/node-fetch": { "version": "2.6.2", @@ -18421,7 +18414,7 @@ "freshmint": { "version": "file:packages/freshmint", "requires": { - "@freshmint/core": "^0.4.0", + "@freshmint/core": "^0.7.0", "@ipld/car": "^4.1.6", "@onflow/decode": "0.0.11", "@onflow/flow-cadut": "^0.2.0-alpha.9", diff --git a/packages/cadence-loader/index.ts b/packages/cadence-loader/index.ts index fe2a22f6..2ba3821e 100644 --- a/packages/cadence-loader/index.ts +++ b/packages/cadence-loader/index.ts @@ -6,7 +6,6 @@ import { withPrefix } from '@onflow/fcl'; // @ts-ignore import { extractImports } from '@onflow/flow-cadut'; -import envsubst from '@tuplo/envsubst'; import { validate } from 'schema-utils'; const schema: any = { @@ -19,17 +18,25 @@ const schema: any = { required: ['flowConfig'], }; -function parseFlowConfigFile(configPath: string) { - const rawConfig = fs.readFileSync(configPath).toString('utf8'); - const substitutedConfig = envsubst(rawConfig); - return JSON.parse(substitutedConfig); +function envsubst(s: string, env: { [key: string]: string }): string { + for (const key in env) { + s = s.replace(`$\{${key}}`, env[key]); + } + + return s; } -function parseFlowConfigFiles(configPaths: string[]) { - const configs = configPaths.map((configPath) => parseFlowConfigFile(configPath)); +function parseFlowConfigFiles(configPaths: string[], env: { [key: string]: string }) { + const configs = configPaths.map((configPath) => parseFlowConfigFile(configPath, env)); return mergeDeep({}, ...configs); } +function parseFlowConfigFile(configPath: string, env: { [key: string]: string }) { + const rawConfig = fs.readFileSync(configPath).toString('utf8'); + const substitutedConfig = envsubst(rawConfig, env); + return JSON.parse(substitutedConfig); +} + function isObject(item: any): boolean { return item && typeof item === 'object' && !Array.isArray(item); } @@ -63,10 +70,10 @@ function mergeDeep(target: any, ...sources: any[]): any { type AddressMap = { [contractName: string]: string }; type AddressMaps = { [network: string]: AddressMap }; -function getAddressMapsFromFlowConfig(configPath: string | string[]): AddressMaps { +function getAddressMapsFromFlowConfig(configPath: string | string[], env: { [key: string]: string }): AddressMaps { const configPaths = Array.isArray(configPath) ? configPath : [configPath]; - const config = parseFlowConfigFiles(configPaths); + const config = parseFlowConfigFiles(configPaths, env); return { emulator: getAddressMapForNetwork(config, 'emulator'), @@ -208,7 +215,9 @@ export default function loader(this: WebpackLoader, source: string): string { validate(schema, options); - const addressMaps = getAddressMapsFromFlowConfig(options.flowConfig); + const env = options.env ?? {}; + + const addressMaps = getAddressMapsFromFlowConfig(options.flowConfig, env); const extractedImports = extractImports(source); diff --git a/packages/cadence-loader/package.json b/packages/cadence-loader/package.json index b2043cba..37f04113 100644 --- a/packages/cadence-loader/package.json +++ b/packages/cadence-loader/package.json @@ -23,7 +23,6 @@ "dependencies": { "@onflow/fcl": "^1.2.0", "@onflow/flow-cadut": "^0.2.0-alpha.9", - "@tuplo/envsubst": "^1.15.0", "react": "^18.2.0", "schema-utils": "^4.0.0" }, diff --git a/packages/freshmint/config.ts b/packages/freshmint/config.ts index face236a..5f1ffd14 100644 --- a/packages/freshmint/config.ts +++ b/packages/freshmint/config.ts @@ -82,9 +82,9 @@ export class MissingContractAccountForNetworkError extends FreshmintError { constructor(network: string) { super( - `Please specify a contract account for the "${network}" network.\n\nExample in ${chalk.green( - 'freshmint.yaml', - )}:${chalk.cyan(`\n\ncontract:\n account:\n ${network}: your-account-name (as defined in flow.json)`)}`, + `Please specify a contract account for the "${network}" network.\n\nExample in freshmint.yaml:${chalk.cyan( + `\n\ncontract:\n account:\n ${network}: your-account-name (as defined in flow.json)`, + )}`, ); this.network = network; } diff --git a/packages/freshmint/generate/templates/web/components/NFTDrop.tsx b/packages/freshmint/generate/templates/web/components/NFTDrop.tsx index cd217f2c..062c6b52 100644 --- a/packages/freshmint/generate/templates/web/components/NFTDrop.tsx +++ b/packages/freshmint/generate/templates/web/components/NFTDrop.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router'; import { Box, Text, Button } from '@chakra-ui/react'; -import { useFCL, useScript, useTransaction, TransactionResult } from '@freshmint/react'; +import { useFCL, useScript, useTransaction, TransactionResult, TransactionStatus } from '@freshmint/react'; import getDrop from '../../cadence/scripts/get_drop.cdc'; import claimNFT from '../../cadence/transactions/claim_nft.cdc'; @@ -13,25 +13,6 @@ interface DropInfo { paymentType: string; } -function parseDropResult(result: any | null): DropInfo | null { - if (result === null) { - return null; - } - - return { - id: result.id, - supply: parseInt(result.supply, 10), - size: parseInt(result.size, 10), - price: parseFloat(result.price), - paymentType: result.paymentVaultType.typeID, - }; -} - -function parseClaimResult(result: TransactionResult): string { - const event = result.events.find((e) => e.type.includes('FreshmintClaimSaleV2.NFTClaimed'))!; - return event.data.nftID; -} - interface DropProps { address: string; id?: string; @@ -43,7 +24,7 @@ export default function NFTDrop({ address, id = 'default' }: DropProps) { const [drop, isLoading] = useScript({ cadence: getDrop, args: [address, id] }, parseDropResult); - const [nftId, claim] = useTransaction(claimNFT, parseClaimResult); + const [nftId, claim, status] = useTransaction(claimNFT, parseClaimResult); if (nftId && currentUser) { router.push(`/nfts/${currentUser.address}/${nftId}`); @@ -58,9 +39,50 @@ export default function NFTDrop({ address, id = 'default' }: DropProps) { {drop.supply > 0 ? `${drop.supply} / ${drop.size} NFTs available.` : `All ${drop.size} NFTs have been claimed.`} - ); } + +function parseDropResult(result: any | null): DropInfo | null { + if (result === null) { + return null; + } + + return { + id: result.id, + supply: parseInt(result.supply, 10), + size: parseInt(result.size, 10), + price: parseFloat(result.price), + paymentType: result.paymentVaultType.typeID, + }; +} + +function parseClaimResult(result: TransactionResult): string { + const event = result.events.find((e) => e.type.includes('FreshmintClaimSaleV2.NFTClaimed'))!; + return event.data.nftID; +} + +function getLoadingText(status: TransactionStatus) { + switch (status) { + case TransactionStatus.SUBMITTED: + return 'Submitting...'; + case TransactionStatus.PENDING: + return 'Executing...'; + case TransactionStatus.EXECUTED: + return 'Verifying...'; + case TransactionStatus.SEALED: + return 'Complete'; + } + + return 'Loading...'; +} diff --git a/packages/freshmint/generate/templates/web/eslintrc.json b/packages/freshmint/generate/templates/web/eslintrc.json index 4d765f28..3bf17177 100644 --- a/packages/freshmint/generate/templates/web/eslintrc.json +++ b/packages/freshmint/generate/templates/web/eslintrc.json @@ -1,3 +1,6 @@ { - "extends": ["next/core-web-vitals", "prettier"] + "extends": ["next/core-web-vitals", "prettier"], + "rules": { + "@typescript-eslint/no-non-null-assertion": "off" + } } diff --git a/packages/freshmint/generate/templates/web/next.config.js b/packages/freshmint/generate/templates/web/next.config.js index 151dddf2..0db9c466 100644 --- a/packages/freshmint/generate/templates/web/next.config.js +++ b/packages/freshmint/generate/templates/web/next.config.js @@ -13,13 +13,13 @@ function getDefaults(network) { return { ACCESS_API: 'http://localhost:8888', WALLET_DISCOVERY: 'http://localhost:8701/fcl/authn', - MINTER_ADDRESS: '0xf8d6e0586b0a20c7' + CONTRACT_ADDRESS: '0xf8d6e0586b0a20c7' } case 'testnet': return { ACCESS_API: 'https://rest-testnet.onflow.org', WALLET_DISCOVERY: 'https://fcl-discovery.onflow.org/testnet/authn', - MINTER_ADDRESS: '' + CONTRACT_ADDRESS: '' } } @@ -44,7 +44,11 @@ const nextConfig = { path.resolve('../flow.json'), path.resolve('../flow.testnet.json'), path.resolve('../flow.mainnet.json'), - ] + ], + env: { + FLOW_TESTNET_ADDRESS: process.env.CONTRACT_ADDRESS, + FLOW_MAINNET_ADDRESS: process.env.CONTRACT_ADDRESS + } }, }, ] @@ -61,7 +65,7 @@ const nextConfig = { DESCRIPTION: process.env.DESCRIPTION || project.description, ACCESS_API: process.env.ACCESS_API || defaults.ACCESS_API, WALLET_DISCOVERY: process.env.WALLET_DISCOVERY || defaults.WALLET_DISCOVERY, - MINTER_ADDRESS: process.env.MINTER_ADDRESS || defaults.MINTER_ADDRESS + CONTRACT_ADDRESS: process.env.CONTRACT_ADDRESS || defaults.CONTRACT_ADDRESS } } diff --git a/packages/freshmint/generate/templates/web/pages/index.tsx b/packages/freshmint/generate/templates/web/pages/index.tsx index 20c7250b..2907c46f 100644 --- a/packages/freshmint/generate/templates/web/pages/index.tsx +++ b/packages/freshmint/generate/templates/web/pages/index.tsx @@ -18,7 +18,7 @@ const Home: NextPage = () => { {process.env.DESCRIPTION} - + ); diff --git a/packages/freshmint/package.json b/packages/freshmint/package.json index 131cd92f..ab05288d 100644 --- a/packages/freshmint/package.json +++ b/packages/freshmint/package.json @@ -21,7 +21,7 @@ "scripts": { "build": "tsup index.ts --clean --minify --format esm,cjs", "postbuild": "cpx --include-empty-dirs \"generate/templates/**\" dist/templates", - "dev": "npm run build -- --watch --onSuccess \"npm run postbuild\"", + "dev": "tsup index.ts --clean --format esm,cjs --watch --onSuccess \"npm run postbuild\"", "lint": "eslint . --ext .ts" }, "dependencies": { diff --git a/packages/react/hooks/useFCL.ts b/packages/react/hooks/useFCL.ts index 764eb214..0432f4e4 100644 --- a/packages/react/hooks/useFCL.ts +++ b/packages/react/hooks/useFCL.ts @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import { useState, useEffect } from 'react'; // @ts-ignore import * as fcl from '@onflow/fcl'; @@ -16,7 +16,7 @@ export interface FCL { } export function useFCL(): FCL { - const [currentUser, setCurrentUser] = React.useState(null); + const [currentUser, setCurrentUser] = useState(null); useEffect(() => { // Only subscribe to user updates if running in a browser environment diff --git a/packages/react/hooks/useTransaction.ts b/packages/react/hooks/useTransaction.ts index 12b0aca9..2777ddc1 100644 --- a/packages/react/hooks/useTransaction.ts +++ b/packages/react/hooks/useTransaction.ts @@ -9,6 +9,7 @@ import { TransactionResult, TransactionResultTransformer, convertToTransactionError, + TransactionStatus, } from '../transaction'; import { FCLModule } from '../fcl'; @@ -17,9 +18,9 @@ export type TransactionExecutor = (...args: any[]) => Promise; export function useTransaction( transaction: Transaction | TransactionParameters | CadenceSourceCode, onResult?: TransactionResultTransformer, -): [T | undefined, TransactionExecutor, boolean] { +): [T | undefined, TransactionExecutor, TransactionStatus] { const [result, setResult] = useState(undefined); - const isLoading = result === undefined; + const [status, setStatus] = useState(TransactionStatus.UNKNOWN); const { fcl, getNetwork } = useFCL(); @@ -32,12 +33,14 @@ export function useTransaction( const network = await getNetwork(); - const result = await executeTransaction(fcl, transactionInstance, network); + const result = await executeTransaction(fcl, transactionInstance, network, (status: TransactionStatus) => + setStatus(status), + ); setResult(result); } - return [result, execute, isLoading]; + return [result, execute, status]; } function normalizeTransaction( @@ -55,11 +58,39 @@ function normalizeTransaction( return new Transaction({ cadence: transaction, args: args }); } -async function executeTransaction(fcl: FCLModule, tx: Transaction, network: string): Promise { +async function executeTransaction( + fcl: FCLModule, + tx: Transaction, + network: string, + onStatus?: (status: any) => void, +): Promise { const transactionId = await sendTransaction(fcl, tx, network); try { - const { events, error } = await fcl.tx({ transactionId }).onceSealed(); + const fclTx = fcl.tx({ transactionId }); + + // Optionally subscribe to transaction status updates + if (onStatus) { + onStatus(TransactionStatus.SUBMITTED); + + fclTx.subscribe((status: { statusCode: number; statusString: string }) => { + // Do not trigger the callback if the status is empty + if (status.statusString === '') { + return; + } + + switch (status.statusString) { + case 'PENDING': + return onStatus(TransactionStatus.PENDING); + case 'EXECUTED': + return onStatus(TransactionStatus.EXECUTED); + case 'SEALED': + return onStatus(TransactionStatus.SEALED); + } + }); + } + + const { events, error } = await fclTx.onceSealed(); if (error) { throw convertToTransactionError(error); } @@ -74,7 +105,7 @@ async function executeTransaction(fcl: FCLModule, tx: Transaction, async function sendTransaction(fcl: FCLModule, tx: Transaction, network: string): Promise { // Do not catch and convert errors that throw when building the transaction - const fclTransaction = await tx.toFCLTransaction(fcl, network); + const fclTransaction = await tx.toFCLTransaction(network); try { // Return the transaction ID diff --git a/packages/react/index.ts b/packages/react/index.ts index 08a8c709..8a509379 100644 --- a/packages/react/index.ts +++ b/packages/react/index.ts @@ -5,5 +5,5 @@ export { useTransaction, useTransaction as useMutate } from './hooks/useTransact export { Script, isScriptParameters } from './script'; export type { ScriptParameters, ScriptResultTransformer } from './script'; -export { Transaction, isTransactionParameters } from './transaction'; +export { Transaction, isTransactionParameters, TransactionStatus } from './transaction'; export type { TransactionParameters, TransactionResult, TransactionResultTransformer } from './transaction'; diff --git a/packages/react/transaction.ts b/packages/react/transaction.ts index 704760ed..34e2cb23 100644 --- a/packages/react/transaction.ts +++ b/packages/react/transaction.ts @@ -2,7 +2,7 @@ import { withPrefix, sansPrefix } from '@onflow/util-address'; import { CadenceSourceCode, resolveCadence, Arguments, resolveArgs } from './cadence'; -import { FCLModule, FCLTransaction } from './fcl'; +import { FCLTransaction } from './fcl'; const toHex = (buffer: Buffer) => buffer.toString('hex'); const fromHex = (hex: string) => Buffer.from(hex, 'hex'); @@ -21,6 +21,14 @@ export type TransactionEvent = { data: { [key: string]: string }; }; +export enum TransactionStatus { + UNKNOWN = 'UNKNOWN', + SUBMITTED = 'SUBMITTED', + PENDING = 'PENDING', + EXECUTED = 'EXECUTED', + SEALED = 'SEALED', +} + export class TransactionAuthorizer { address: string; keyIndex: number; @@ -36,7 +44,7 @@ export class TransactionAuthorizer { return async (account = {}) => { return { ...account, - tempId: 'SIGNER', + tempId: `${withPrefix(this.address)}-${this.keyIndex}`, addr: sansPrefix(this.address), keyId: this.keyIndex, signingFunction: (data: { message: string }) => ({ @@ -105,7 +113,7 @@ export class Transaction { ); } - async toFCLTransaction(fcl: FCLModule, network: string): Promise { + async toFCLTransaction(network: string): Promise { const limit = this.computeLimit ?? Transaction.defaultComputeLimit; const cadence = resolveCadence(this.cadence, network); const args = await resolveArgs(cadence, this.args ?? []); @@ -114,7 +122,7 @@ export class Transaction { cadence, args, limit, - ...this.getAuthorizations(fcl, this.signers), + ...this.getAuthorizations(this.signers), }; } @@ -122,7 +130,7 @@ export class Transaction { return await this._onResult(result); } - private getAuthorizations(fcl: FCLModule, signers: TransactionSigners | undefined) { + private getAuthorizations(signers: TransactionSigners | undefined) { if (signers) { return { payer: signers.payer.toFCLAuthorizationFunction(),