Skip to content

Commit

Permalink
feat(frontend): 🎸 implement token association & nft claiming
Browse files Browse the repository at this point in the history
  • Loading branch information
apttx committed Dec 19, 2024
1 parent 2f0afbb commit b7f1746
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 5 deletions.
16 changes: 12 additions & 4 deletions frontend/src/lib/hashconnect/HashConnectLoader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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()
</script>

Expand All @@ -33,7 +37,11 @@
{:else}
<p>Please connect your wallet using the button at the top of the page.</p>
{/if}
{:else if paired}
{@render paired({ hashConnect })}
{:else if !isWithAccountInformation(hashConnect)}
{#if paired}
{@render paired({ hashConnect })}
{/if}
{:else if withAccountInformation}
{@render withAccountInformation({ hashConnect })}
{/if}
</div>
75 changes: 74 additions & 1 deletion frontend/src/lib/hashconnect/hashConnect.svelte.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
*/
Expand All @@ -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
*
Expand All @@ -55,6 +70,12 @@ export type InitializedReactiveHashConnect = Omit<ReactiveHashConnect, 'connect'
export type PairedReactiveHashConnect = Omit<ReactiveHashConnect, 'connect' | 'session'> &
Pick<Required<ReactiveHashConnect>, 'connect' | 'session'>

export type ReactiveHashConnectWithAccountInformation = Omit<
ReactiveHashConnect,
'connect' | 'session' | 'accountInformation'
> &
Pick<Required<ReactiveHashConnect>, 'connect' | 'session' | 'accountInformation'>

export const isInitialized = (
reactiveHashConnect: ReactiveHashConnect,
): reactiveHashConnect is InitializedReactiveHashConnect => {
Expand All @@ -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
Expand Down Expand Up @@ -130,6 +161,30 @@ const loadSelectedLedgerId = () => {

return selectedLedgerId
}

export const getAccountInformation = async (options: {
ledgerId: LedgerId
accountId: AccountId
tokenId: TokenId
}): Promise<AccountInformation> => {
// 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<HashConnect>()
let hasHashConnectInstanceInitialized = $state(false)
Expand All @@ -154,6 +209,7 @@ const connect = $derived.by(() => {

return connect
})
let accountInformation = $state<AccountInformation>()

const startSubscribing = (hashConnectInstance: HashConnect) => {
// set up event subscribers
Expand Down Expand Up @@ -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 = {
Expand All @@ -238,4 +308,7 @@ export const hashConnect: ReactiveHashConnect = {
get session() {
return session
},
get accountInformation() {
return accountInformation
},
}
21 changes: 21 additions & 0 deletions frontend/src/lib/hedera/collection/associate.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
72 changes: 72 additions & 0 deletions frontend/src/lib/hedera/collection/claim.ts
Original file line number Diff line number Diff line change
@@ -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<NFTContractAbi, 'claimNft'>()
.addAddress(tokenAddress)
.addInt64(options.serialNumber)
const claimNftTransaction = TypedContractExecuteTransaction<NFTContractAbi>({
contractId: options.contractId as TypedContractId<NFTContractAbi>,
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 (
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/routes/certificates/[serialNumber]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,50 @@
<script lang="ts">
import containerStyles from '$lib/css/container.module.css'
import { contractId, nftTokenId } from '$lib/deployment.js'
import HashConnectLoader from '$lib/hashconnect/HashConnectLoader.svelte'
import { associateWithToken } from '$lib/hedera/collection/associate'
import { claimNftWithExecutor } from '$lib/hedera/collection/claim.js'
let { data } = $props()
</script>

<main class={containerStyles.container}>
<h1>{data.nft.name} #{data.nft.serialNumber}</h1>

<HashConnectLoader>
{#snippet withAccountInformation({ hashConnect })}
{#if hashConnect.accountInformation.hasAssociatedWithToken}
<button
onclick={() => {
claimNftWithExecutor({
contractId: contractId,
tokenId: nftTokenId,
serialNumber: data.nft.serialNumber,
executeTransaction: hashConnect.session.executeTransaction,
})
}}
>
Claim
</button>
{:else}
<button
onclick={async () => {
const associateWithTokenResult = await associateWithToken({
accountId: hashConnect.session.accountId,
tokenId: nftTokenId,
executeTransaction: hashConnect.session.executeTransaction,
})

hashConnect.accountInformation.hasAssociatedWithToken =
associateWithTokenResult.hasAssociatedWithToken
}}
>
Associate with BIDI
</button>
{/if}
{/snippet}
</HashConnectLoader>

<img src={data.nft.imageUrl} alt="Image of certificate #{data.nft.serialNumber}" />

<p>{new Date(data.nft.certificate.dateOfWork).toLocaleDateString()}</p>
Expand Down

0 comments on commit b7f1746

Please sign in to comment.