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: hashpack #3

Merged
merged 6 commits into from
Dec 10, 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
2 changes: 2 additions & 0 deletions .github/workflows/continuousIntgration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
HEDERA_ACCOUNT_ID: ${{ secrets.HEDERA_ACCOUNT_ID }}
HEDERA_PRIVATE_KEY: ${{ secrets.HEDERA_PRIVATE_KEY }}
HEDERA_ENVIRONMENT: ${{ vars.HEDERA_ENVIRONMENT }}
PUBLIC_HASHCONNECT_PROJECT_ID: ${{ vars.PUBLIC_HASHCONNECT_PROJECT_ID }}
steps:
# setup
- uses: actions/checkout@v4
Expand All @@ -32,6 +33,7 @@ jobs:
HEDERA_ACCOUNT_ID: ${{ secrets.HEDERA_ACCOUNT_ID }}
HEDERA_PRIVATE_KEY: ${{ secrets.HEDERA_PRIVATE_KEY }}
HEDERA_ENVIRONMENT: ${{ vars.HEDERA_ENVIRONMENT }}
PUBLIC_HASHCONNECT_PROJECT_ID: ${{ vars.PUBLIC_HASHCONNECT_PROJECT_ID }}
steps:
# setup
- uses: actions/checkout@v4
Expand Down
4 changes: 3 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ HEDERA_ACCOUNT_ID=
HEDERA_PRIVATE_KEY=

# testnet / mainnet
HEDERA_ENVIRONMENT=
HEDERA_ENVIRONMENT=
# the reown (formerly walletconnect) project id used for hashpack
PUBLIC_HASHCONNECT_PROJECT_ID=3918c43fa2467261721ed00df472e8be
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"hashconnect": "^3.0.13",
"prettier": "^3.3.2",
"prettier-plugin-css-order": "^2.1.2",
"prettier-plugin-organize-imports": "^4.1.0",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/custom.d.ts/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'
}
8 changes: 8 additions & 0 deletions frontend/src/lib/hashconnect/dappMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DappMetadata } from 'virtual:hashconnect'

export const dappMetadata: DappMetadata = {
name: 'BIDI',
description: "Bbla bla bla and more bla and showcasing blokk.'s Hedera expertise.",
url: 'https://github.com/blokk-studio/bidi',
icons: [],
}
189 changes: 189 additions & 0 deletions frontend/src/lib/hashconnect/hashConnect.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { dev } from '$app/environment'
import { PUBLIC_HASHCONNECT_PROJECT_ID } from '$env/static/public'
import type { Execute } from '$lib/hedera/Execute'
import { AccountId, LedgerId } from '@hashgraph/sdk'
import { untrack } from 'svelte'
import { HashConnect, type SessionData } from 'virtual:hashconnect'
import { dappMetadata } from './dappMetadata'

/** an accurate type to reflect account id strings */
type AccountIdString = `${number}.${number}.${number}`
/** an accurate type union to reflect the possible network names */
type NetworkName = 'testnet' | 'mainnet'
/**
* a custom representation of session data in a format most useful to us
*
* the property names are intentionally different to distinguish them from @hashgraph/sdk exports such as AccountId & LedgerId.
*
* the string properties might be replaced by @hashgraph/sdk's AccountId & LedgerId later on.
*/
interface ReactiveHashConnectSession {
/** the user's account id in its string representation */
accountIdString: AccountIdString
accountId: AccountId
/** the name of the chain we're currently sending transactions to */
networkName: NetworkName
disconnect: () => void
execute: Execute
}

/**
* a custom interface to hashconnect with reactive state that exposes only the things we need
*/
export interface ReactiveHashConnect {
/**
* the ledger id setting reflecting the chain the user would like to use
*
* does not reflect the current connection/session unlike {@link ReactiveHashConnectSession.networkName}.
*/
ledgerId: LedgerId
/**
* the current hashconnect session, if paired
*
* is `undefined` if not paired or no account id is available in the hashconnect session
*/
readonly session?: ReactiveHashConnectSession
/**
* the function to connect to the wallet
*
* is undefined while the extension is initializing
*/
readonly connect?: () => void
}

const getSession = (options: {
hashConnectInstance: HashConnect
sessionData: SessionData
}): ReactiveHashConnectSession | undefined => {
const firstAccountIdString = options.sessionData.accountIds[0]
if (!firstAccountIdString) {
return
}

const networkName = options.sessionData.network
const accountId = AccountId.fromString(firstAccountIdString)
const disconnect = options.hashConnectInstance.disconnect.bind(hashConnectInstance)
const execute: Execute = (query) => {
const signer = options.hashConnectInstance.getSigner(
accountId as unknown as Parameters<HashConnect['getSigner']>[0],
)

return query.executeWithSigner(
signer as unknown as Parameters<(typeof query)['executeWithSigner']>[0],
)
}

// plain string values for now, but might be replaced by AccountId & LedgerId
return {
accountIdString: firstAccountIdString as AccountIdString,
networkName: networkName as NetworkName,
accountId,
disconnect,
execute,
}
}

let ledgerId = $state(LedgerId.TESTNET)
let hashConnectInstance = $state<HashConnect>()
let hasHashConnectInstanceInitialized = $state(false)
let sessionData = $state<SessionData>()
const session = $derived.by(() => {
if (!hashConnectInstance || !hasHashConnectInstanceInitialized) {
return
}

if (!sessionData) {
return
}

return getSession({ sessionData, hashConnectInstance })
})
const connect = $derived.by(() => {
if (!hashConnectInstance || !hasHashConnectInstanceInitialized) {
return
}

const connect = hashConnectInstance.openPairingModal.bind(hashConnectInstance)

return connect
})

const startSubscribing = (hashConnectInstance: HashConnect) => {
// set up event subscribers
const updateSessionData = (newSessionData: SessionData) => {
sessionData = newSessionData
}
const unsetSessionData = () => {
sessionData = undefined
}

hashConnectInstance.pairingEvent.on(updateSessionData)
hashConnectInstance.disconnectionEvent.on(unsetSessionData)

return () => {
hashConnectInstance.pairingEvent.off(updateSessionData)
hashConnectInstance.disconnectionEvent.off(unsetSessionData)
}
}

$effect.root(() => {
// recreate the hashconnect instance whenever the ledger id changes
$effect(() => {
// access the value to trigger reactivity
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
ledgerId

// only re-run this when the ledger id changes (infinite loop)
untrack(() => {
sessionData = undefined
hasHashConnectInstanceInitialized = false

hashConnectInstance = new HashConnect(
ledgerId,
PUBLIC_HASHCONNECT_PROJECT_ID,
dappMetadata,
// debug during development
dev,
)
// start the initialization
hashConnectInstance.init().then(() => {
hasHashConnectInstanceInitialized = true
})
})
})

// subscribe whenever the hashconnect instance changes
$effect(() => {
// don't do anything if there is no instance
if (!hashConnectInstance) {
return
}

const stopSubscribing = startSubscribing(hashConnectInstance)

// when the effect is destroyed
const destroy = () => {
stopSubscribing()
}

return destroy
})
})

export const hashConnect = {
// ledger id is read-write
get ledgerId() {
return ledgerId
},
set ledgerId(newLedgerId) {
ledgerId = newLedgerId
},
// readonly pairing initialization method
get connect() {
return connect
},
// readonly session
get session() {
return session
},
}
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')
}
},
}
}
3 changes: 3 additions & 0 deletions frontend/src/lib/hedera/Execute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { Query } from '@hashgraph/sdk'

export type Execute = <QueryOutput>(query: Query<QueryOutput>) => Promise<QueryOutput>
38 changes: 38 additions & 0 deletions frontend/src/routes/dashboard/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import { LedgerId } from '@hashgraph/sdk'
import HashConnectLoader from './HashConnectLoader.svelte'

let { children } = $props()
</script>

<HashConnectLoader>
{#snippet children({ hashConnect })}
{#if hashConnect.session}
Connected to {hashConnect.session.networkName} with {hashConnect.session.accountIdString}

{#if hashConnect.session}
<button onclick={hashConnect.session.disconnect}>disconnect</button>
{/if}
{:else}
<button
onclick={() => {
hashConnect.connect()
}}
>
Connect HashPack
</button>
{/if}

<select
value={hashConnect.ledgerId}
oninput={(event) => {
hashConnect.ledgerId = LedgerId.fromString(event.currentTarget.value)
}}
>
<option value={LedgerId.TESTNET}>{LedgerId.TESTNET}</option>
<option value={LedgerId.MAINNET}>{LedgerId.MAINNET}</option>
</select>
{/snippet}
</HashConnectLoader>

{@render children()}
6 changes: 6 additions & 0 deletions frontend/src/routes/dashboard/+layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// all dashboard functionality is client-only because of the crypto wallet
export const ssr = false

export const load = ({ depends }) => {
depends('hashConnect:ledgerId')
}
33 changes: 33 additions & 0 deletions frontend/src/routes/dashboard/HashConnectLoader.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts" module>
type InitializedReactiveHashConnect = Omit<ReactiveHashConnect, 'connect'> &
Pick<Required<ReactiveHashConnect>, 'connect'>

const isInitialized = (
reactiveHashConnect: ReactiveHashConnect,
): reactiveHashConnect is InitializedReactiveHashConnect => {
return !!reactiveHashConnect.connect
}
</script>

<script lang="ts">
import type { Snippet } from 'svelte'
import { hashConnect, type ReactiveHashConnect } from '../../lib/hashconnect/hashConnect.svelte'

let {
children,
}: {
children: Snippet<
[
{
hashConnect: InitializedReactiveHashConnect
},
]
>
} = $props()
</script>

{#if !isInitialized(hashConnect)}
<span>Loading HashConnect</span>
{:else}
{@render children({ hashConnect })}
{/if}
3 changes: 2 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
import { getVirtualHashConnectPlugin } from './src/lib/hashconnect/virtualHashConnect'

export default defineConfig({
plugins: [sveltekit()],
plugins: [getVirtualHashConnectPlugin(), sveltekit()],
})
Loading
Loading