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

feature: claiming #19

Merged
merged 2 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
2 changes: 1 addition & 1 deletion frontend/src/lib/hashconnect/dappMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DappMetadata } from 'hashconnect'
import type { DappMetadata } from 'virtual:hashconnect'

export const dappMetadata: DappMetadata = {
name: 'BIDI',
Expand Down
77 changes: 75 additions & 2 deletions frontend/src/lib/hashconnect/hashConnect.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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 { HashConnect, type SessionData } from 'hashconnect'
import { MirrorNodeClient } from '$lib/hedera/MirrorNodeClient'
import { AccountId, LedgerId, TokenId } from '@hashgraph/sdk'
import { tokenUtils as getTokenUtils } from '@tikz/hedera-mirror-node-ts'
import type { HashConnectSigner } from 'hashconnect/dist/signer'
import { untrack } from 'svelte'
import { HashConnect, type SessionData } from 'virtual:hashconnect'
import { dappMetadata } from './dappMetadata'

/**
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
},
}
3 changes: 3 additions & 0 deletions frontend/src/lib/hashconnect/virtual:hashconnect.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module 'virtual:hashconnect' {
export * from 'hashconnect'
}
19 changes: 19 additions & 0 deletions frontend/src/lib/hashconnect/virtualHashConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type Plugin } from 'vite'

/**
* a compatibility layer to import `hashconnect` through vite instead of allowing sveltekit to process it
*
* `hashconnect` can't be imported by node directly because it uses imports without extensions in its dist files. importing through sveltekit code doesn't work because of this, because sveltekit's processing is not as forgiving as vite's. but we can circumvent this by creating a virtual module that uses vite's module resolution to create a working bundle of `hashconnect` (as a pre-bundled dependency).
*/
export const getVirtualHashConnectPlugin = (): Plugin => {
const virtualModuleId = 'virtual:hashconnect'

return {
name: 'virtual-hashconnect', // required, will show up in warnings and errors
async resolveId(id) {
if (id === virtualModuleId) {
return await this.resolve('hashconnect')
}
},
}
}
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
3 changes: 2 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
import { getVirtualHashConnectPlugin } from './src/lib/hashconnect/virtualHashConnect'

export default defineConfig({
plugins: [nodePolyfills(), sveltekit()],
plugins: [nodePolyfills(), getVirtualHashConnectPlugin(), sveltekit()],
ssr: {
noExternal: ['@tikz/hedera-mirror-node-ts'],
},
Expand Down
Loading