From b7f17462a34702481555080cb6fefd1e5704e07a Mon Sep 17 00:00:00 2001 From: aptt Date: Thu, 19 Dec 2024 10:42:59 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=F0=9F=8E=B8=20implement=20to?= =?UTF-8?q?ken=20association=20&=20nft=20claiming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/hashconnect/HashConnectLoader.svelte | 16 +++- .../src/lib/hashconnect/hashConnect.svelte.ts | 75 ++++++++++++++++++- .../src/lib/hedera/collection/associate.ts | 21 ++++++ frontend/src/lib/hedera/collection/claim.ts | 72 ++++++++++++++++++ .../certificates/[serialNumber]/+page.svelte | 38 ++++++++++ 5 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/hedera/collection/associate.ts diff --git a/frontend/src/lib/hashconnect/HashConnectLoader.svelte b/frontend/src/lib/hashconnect/HashConnectLoader.svelte index 5d1db3a..d2fbea1 100644 --- a/frontend/src/lib/hashconnect/HashConnectLoader.svelte +++ b/frontend/src/lib/hashconnect/HashConnectLoader.svelte @@ -7,16 +7,20 @@ type InitializedReactiveHashConnect, type PairedReactiveHashConnect, type UninitializedReactiveHashConnect, + type ReactiveHashConnectWithAccountInformation, + isWithAccountInformation, } from './hashConnect.svelte' let { - paired, - initialized, loading, + initialized, + paired, + withAccountInformation = paired, }: { loading?: Snippet<[{ hashConnect: UninitializedReactiveHashConnect }]> initialized?: Snippet<[{ hashConnect: InitializedReactiveHashConnect }]> paired?: Snippet<[{ hashConnect: PairedReactiveHashConnect }]> + withAccountInformation?: Snippet<[{ hashConnect: ReactiveHashConnectWithAccountInformation }]> } = $props() @@ -33,7 +37,11 @@ {:else}

Please connect your wallet using the button at the top of the page.

{/if} - {:else if paired} - {@render paired({ hashConnect })} + {:else if !isWithAccountInformation(hashConnect)} + {#if paired} + {@render paired({ hashConnect })} + {/if} + {:else if withAccountInformation} + {@render withAccountInformation({ hashConnect })} {/if} diff --git a/frontend/src/lib/hashconnect/hashConnect.svelte.ts b/frontend/src/lib/hashconnect/hashConnect.svelte.ts index b752a87..a43b7f8 100644 --- a/frontend/src/lib/hashconnect/hashConnect.svelte.ts +++ b/frontend/src/lib/hashconnect/hashConnect.svelte.ts @@ -1,7 +1,10 @@ import { dev } from '$app/environment' import { PUBLIC_HASHCONNECT_PROJECT_ID } from '$env/static/public' +import { nftTokenId } from '$lib/deployment' import type { ExecuteTransaction } from '$lib/hedera/Execute' -import { AccountId, LedgerId } from '@hashgraph/sdk' +import { MirrorNodeClient } from '$lib/hedera/MirrorNodeClient' +import { AccountId, LedgerId, TokenId } from '@hashgraph/sdk' +import { tokenUtils as getTokenUtils } from '@tikz/hedera-mirror-node-ts' import { HashConnect, type SessionData } from 'hashconnect' import type { HashConnectSigner } from 'hashconnect/dist/signer' import { untrack } from 'svelte' @@ -21,6 +24,12 @@ interface ReactiveHashConnectSession { executeTransaction: ExecuteTransaction } +/** extra information about the user's account that is fetched from the mirror node api */ +export interface AccountInformation { + /** whether the user has associated with the nft token */ + hasAssociatedWithToken: boolean +} + /** * a custom interface to hashconnect with reactive state that exposes only the things we need */ @@ -37,6 +46,12 @@ export interface ReactiveHashConnect { * is `undefined` if not paired or no account id is available in the hashconnect session */ readonly session?: ReactiveHashConnectSession + /** + * information about the account of the current session + * + * is `undefined` there is no session + */ + readonly accountInformation?: AccountInformation /** * the function to connect to the wallet * @@ -55,6 +70,12 @@ export type InitializedReactiveHashConnect = Omit & Pick, 'connect' | 'session'> +export type ReactiveHashConnectWithAccountInformation = Omit< + ReactiveHashConnect, + 'connect' | 'session' | 'accountInformation' +> & + Pick, 'connect' | 'session' | 'accountInformation'> + export const isInitialized = ( reactiveHashConnect: ReactiveHashConnect, ): reactiveHashConnect is InitializedReactiveHashConnect => { @@ -67,6 +88,16 @@ export const isPaired = ( return !!reactiveHashConnect.connect && !!reactiveHashConnect.session } +export const isWithAccountInformation = ( + reactiveHashConnect: ReactiveHashConnect, +): reactiveHashConnect is ReactiveHashConnectWithAccountInformation => { + return ( + !!reactiveHashConnect.connect && + !!reactiveHashConnect.session && + !!reactiveHashConnect.accountInformation + ) +} + const getSession = (options: { hashConnectInstance: HashConnect sessionData: SessionData @@ -130,6 +161,30 @@ const loadSelectedLedgerId = () => { return selectedLedgerId } + +export const getAccountInformation = async (options: { + ledgerId: LedgerId + accountId: AccountId + tokenId: TokenId +}): Promise => { + // using global fetch because this only runs client-side + const client = MirrorNodeClient.newFromLedgerId(options.ledgerId, { fetch }) + const tokenUtils = getTokenUtils(client) + const tokenIdString = options.tokenId.toString() + const tokensRequest = tokenUtils.Tokens.setAccountId(options.accountId.toString()).setTokenId( + tokenIdString, + ) + const tokensResponse = await tokensRequest.get() + const token = tokensResponse.tokens.find((token) => { + return token.token_id === tokenIdString + }) + const hasAssociatedWithToken = !!token + + return { + hasAssociatedWithToken, + } +} + let selectedLedgerId = $state(loadSelectedLedgerId()) let hashConnectInstance = $state() let hasHashConnectInstanceInitialized = $state(false) @@ -154,6 +209,7 @@ const connect = $derived.by(() => { return connect }) +let accountInformation = $state() const startSubscribing = (hashConnectInstance: HashConnect) => { // set up event subscribers @@ -220,6 +276,20 @@ $effect.root(() => { return destroy }) + + $effect(() => { + if (!session) { + return + } + + getAccountInformation({ + accountId: session.accountId, + ledgerId: session.ledgerId, + tokenId: nftTokenId, + }).then((newAccountInformation) => { + accountInformation = newAccountInformation + }) + }) }) export const hashConnect: ReactiveHashConnect = { @@ -238,4 +308,7 @@ export const hashConnect: ReactiveHashConnect = { get session() { return session }, + get accountInformation() { + return accountInformation + }, } diff --git a/frontend/src/lib/hedera/collection/associate.ts b/frontend/src/lib/hedera/collection/associate.ts new file mode 100644 index 0000000..891353b --- /dev/null +++ b/frontend/src/lib/hedera/collection/associate.ts @@ -0,0 +1,21 @@ +import { AccountId, Status, TokenAssociateTransaction, TokenId } from '@hashgraph/sdk' +import type { ExecuteTransaction } from '../Execute' + +export const associateWithToken = async (options: { + accountId: AccountId + tokenId: TokenId + executeTransaction: ExecuteTransaction +}) => { + const tokenAssociateTransaction = new TokenAssociateTransaction() + .setTokenIds([options.tokenId]) + .setAccountId(options.accountId) + const receipt = await options.executeTransaction(tokenAssociateTransaction) + + if (receipt.status !== Status.Success) { + throw new Error(`transaction wasn't successful: ${receipt.toString()}`) + } + + return { + hasAssociatedWithToken: true, + } +} diff --git a/frontend/src/lib/hedera/collection/claim.ts b/frontend/src/lib/hedera/collection/claim.ts index 184bd01..8b6d03c 100644 --- a/frontend/src/lib/hedera/collection/claim.ts +++ b/frontend/src/lib/hedera/collection/claim.ts @@ -1,9 +1,81 @@ +import { type NFTContractAbi } from '$lib/NFTContractAbi' import { AccountId, Client, ContractExecuteTransaction, ContractFunctionParameters, + ContractId, + Status, + TokenId, } from '@hashgraph/sdk' +import type { ExecuteTransaction } from '../Execute' +import { + TypedContractExecuteTransaction, + TypedContractFunctionParameters, + type Address, + type TypedContractId, +} from '../typedTransactions' + +export const claimNftWithExecutor = async (options: { + contractId: ContractId + tokenId: TokenId + serialNumber: number + executeTransaction: ExecuteTransaction +}) => { + console.log('----- Claiming NFT -----') + console.log(`Initiating claim for NFT #${options.serialNumber}`) + + try { + // Convert token ID to solidity address format + const tokenAddress = options.tokenId.toSolidityAddress() as Address + + // Create the claim transaction + const claimNftFunctionParameters = TypedContractFunctionParameters() + .addAddress(tokenAddress) + .addInt64(options.serialNumber) + const claimNftTransaction = TypedContractExecuteTransaction({ + contractId: options.contractId as TypedContractId, + functionName: 'claimNft', + gas: 1000000, + functionParameters: claimNftFunctionParameters, + }) + + // Execute the claim + const receipt = await options.executeTransaction(claimNftTransaction) + + if (receipt.status !== Status.Success) { + console.log(`Claim failed with response code: ${receipt.status}`) + + return false + } + + console.log(`NFT claimed successfully!`) + console.log(`Claim Details:`) + console.log(` - Token ID: ${options.tokenId}`) + console.log(` - Serial Number: ${options.serialNumber}`) + console.log( + `View NFT: https://hashscan.io/testnet/token/${options.tokenId}/${options.serialNumber}`, + ) + + return true + } catch (error) { + if (!(error instanceof Error)) { + console.log('Unknown error claiming NFT:', error) + + return false + } + + if (error.message.includes('You are not authorized to claim this NFT')) { + console.log('Error: You are not authorized to claim this NFT') + } else if (error.message.includes('NFT has already been claimed')) { + console.log('Error: This NFT has already been claimed') + } else { + console.log('Error claiming NFT:', error.message) + } + } + + return false +} // example: claimNFT("0.0.1234", "0.0.56789", "1", client) export const claimNFT = async ( diff --git a/frontend/src/routes/certificates/[serialNumber]/+page.svelte b/frontend/src/routes/certificates/[serialNumber]/+page.svelte index b547e49..fe83167 100644 --- a/frontend/src/routes/certificates/[serialNumber]/+page.svelte +++ b/frontend/src/routes/certificates/[serialNumber]/+page.svelte @@ -1,5 +1,9 @@ @@ -7,6 +11,40 @@

{data.nft.name} #{data.nft.serialNumber}

+ + {#snippet withAccountInformation({ hashConnect })} + {#if hashConnect.accountInformation.hasAssociatedWithToken} + + {:else} + + {/if} + {/snippet} + + Image of certificate #{data.nft.serialNumber}

{new Date(data.nft.certificate.dateOfWork).toLocaleDateString()}