Skip to content
This repository was archived by the owner on Jan 30, 2024. It is now read-only.
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
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const config: Config = {
fakeTimers: {
enableGlobally: true,
},
testMatch: ['**/?(*.)+(spec|test).ts?(x)', '!**/DAppConnector.test.ts'],
}

export default config
847 changes: 811 additions & 36 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"scripts": {
"build": "tsc",
"test": "jest",
"test:connect": "jest --testMatch '**/DAppConnector.test.ts' --verbose",
"prepublishOnly": "npm run build"
},
"author": "",
Expand All @@ -24,12 +25,16 @@
"@babel/preset-env": "^7.22.10",
"@babel/preset-typescript": "^7.22.5",
"@types/jest": "^29.5.3",
"@walletconnect/types": "^2.9.2",
"jest": "^29.6.2",
"lokijs": "^1.5.12",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it doesn't look like this is being used in this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is needed to run Wallet Connect libraries in NodeJS (to run tests). It is suggested by the WalletConnect package itself. Maybe we can remove this package as it has only added one simple test until we increase the number of tests.

"ts-node": "^10.9.1",
"typescript": "^5.1.6"
},
"dependencies": {
"@hashgraph/sdk": "^2.31.0"
"@hashgraph/sdk": "^2.31.0",
"@walletconnect/qrcode-modal": "^1.8.0",
"@walletconnect/sign-client": "^2.10.0",
"@walletconnect/types": "^2.10.0",
"@walletconnect/utils": "^2.10.0"
}
}
}
203 changes: 203 additions & 0 deletions src/DAppConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { AccountId, LedgerId } from '@hashgraph/sdk'
import { SessionTypes, SignClientTypes } from '@walletconnect/types'
import QRCodeModal from '@walletconnect/qrcode-modal'
import Client, { SignClient } from '@walletconnect/sign-client'
import { getSdkError } from '@walletconnect/utils'
import { HederaJsonRpcMethods, accountAndLedgerFromSession, networkNamespaces } from './lib'
import { DAppSigner } from './providers/DAppSigner'

type BaseLogger = 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'fatal'

export class DAppConnector {
dAppMetadata: SignClientTypes.Metadata
network: LedgerId = LedgerId.TESTNET
projectId?: string
supportedMethods: string[] = []
supportedEvents: string[] = []

walletConnectClient: Client | null = null
signers: DAppSigner[] = []
isInitializing = false

constructor(
metadata: SignClientTypes.Metadata,
network: LedgerId,
projectId: string | undefined,
methods?: string[],
events?: string[],
) {
this.dAppMetadata = metadata
this.network = network
this.supportedMethods = methods ?? Object.values(HederaJsonRpcMethods)
this.supportedEvents = events ?? []
this.projectId = projectId
}

async init({ logger }: { logger?: BaseLogger } = {}) {
try {
this.isInitializing = true
if (!this.projectId) {
throw new Error('Project ID is not defined')
}
this.walletConnectClient = await SignClient.init({
logger,
relayUrl: 'wss://relay.walletconnect.com',
projectId: this.projectId,
metadata: this.dAppMetadata,
})
const existingSession = await this.checkPersistedState()
existingSession.forEach(async (session) => {
await this.onSessionConnected(session)
})
} finally {
this.isInitializing = false
}
}

public async connectQR(pairingTopic?: string): Promise<void> {
return this.abortableConnect(async () => {
try {
const { uri, approval } = await this.connectURI(pairingTopic)
if (!uri) throw new Error('URI is not defined')
QRCodeModal.open(uri, () => {
throw new Error('User rejected pairing')
})

await this.onSessionConnected(await approval())
} finally {
QRCodeModal.close()
}
})
}

public async connect(
launchCallback: (uri: string) => void,
pairingTopic?: string,
): Promise<void> {
return this.abortableConnect(async () => {
const { uri, approval } = await this.connectURI(pairingTopic)
if (!uri) throw new Error('URI is not defined')
launchCallback(uri)
const session = await approval()
await this.onSessionConnected(session)
})
}

private abortableConnect = async <T>(callback: () => Promise<T>): Promise<T> => {
const pairTimeoutMs = 480_000
const timeout = setTimeout(() => {
QRCodeModal.close()
throw new Error(`Connect timed out after ${pairTimeoutMs}(ms)`)
}, pairTimeoutMs)

try {
return await callback()
} finally {
clearTimeout(timeout)
}
}

public async disconnect(topic: string): Promise<void> {
if (!this.walletConnectClient) {
throw new Error('WalletConnect is not initialized')
}
if (!topic) {
throw new Error('No topic provided')
}

await this.walletConnectClient.disconnect({
topic,
reason: getSdkError('USER_DISCONNECTED'),
})
}

private createSigners(session: SessionTypes.Struct): DAppSigner[] {
const allNamespaceAccounts = accountAndLedgerFromSession(session)
return allNamespaceAccounts.map(
({ account, network }: { account: AccountId; network: LedgerId }) =>
new DAppSigner(account, this.walletConnectClient!, session.topic, network),
)
}

private async onSessionConnected(session: SessionTypes.Struct) {
this.signers.push(...this.createSigners(session))
}

private async pingWithRetry(topic: string, retries = 3): Promise<void> {
try {
await this.walletConnectClient!.ping({ topic })
} catch (error) {
if (retries > 0) {
console.log(`Ping failed, ${retries} retries left. Retrying in 1 seconds...`)
await new Promise((resolve) => setTimeout(resolve, 1000))
await this.pingWithRetry(topic, retries - 1)
} else {
console.log(`Ping failed after ${retries} retries. Aborting...`)
throw error
}
}
}

private async checkPersistedState() {
if (!this.walletConnectClient) {
throw new Error('WalletConnect is not initialized')
}

if (this.walletConnectClient.session.length) {
const sessionCheckPromises: Promise<SessionTypes.Struct>[] =
this.walletConnectClient.session.getAll().map(
(session: SessionTypes.Struct) =>
new Promise(async (resolve, reject) => {
try {
await this.pingWithRetry(session.topic)
resolve(session)
} catch (error) {
try {
console.log('Ping failed, disconnecting from session. Topic: ', session.topic)
await this.walletConnectClient!.disconnect({
topic: session.topic,
reason: getSdkError('SESSION_SETTLEMENT_FAILED'),
})
} catch (e) {
console.log('Non existing session with topic:', session.topic)
reject('Non existing session')
}
}
}),
)
const sessionCheckResults = (await Promise.allSettled(sessionCheckPromises)) as {
status: 'fulfilled' | 'rejected'
value: SessionTypes.Struct
}[]

const sessions = sessionCheckResults
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value as SessionTypes.Struct)

const errors = sessionCheckResults.filter((result) => result.status === 'rejected')
if (errors.length) {
console.log('Errors while checking persisted state:', errors)
}

return sessions
}

return []
}

private async connectURI(
pairingTopic?: string,
): Promise<{ uri?: string; approval: () => Promise<SessionTypes.Struct> }> {
if (!this.walletConnectClient) {
throw new Error('WalletConnect is not initialized')
}
return this.walletConnectClient.connect({
pairingTopic,
requiredNamespaces: networkNamespaces(
this.network,
this.supportedMethods,
this.supportedEvents,
),
})
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './lib'
export * from './types'
export { DAppConnector } from './DAppConnector'
41 changes: 41 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Buffer } from 'buffer'
import { AccountId, Transaction, LedgerId } from '@hashgraph/sdk'
import { ProposalTypes, SessionTypes } from '@walletconnect/types'

/**
* Freezes a transaction if it is not already frozen. Transactions must
Expand Down Expand Up @@ -161,3 +163,42 @@ export function networkNameToCAIPChainId(networkName: string): string {
const chainId = ledgerIdToCAIPChainId(ledgerId)
return chainId
}

/**
* Create a `ProposalTypes.RequiredNamespaces` object for a given ledgerId.
* @param ledgerId LeggarId
* @param methods string[]
* @param events string[]
* @returns `ProposalTypes.RequiredNamespaces`
*/
export const networkNamespaces = (
ledgerId: LedgerId,
methods: string[],
events: string[],
): ProposalTypes.RequiredNamespaces => ({
hedera: {
chains: [ledgerIdToCAIPChainId(ledgerId)],
methods,
events,
},
})

/**
* Get the account and ledger from a `SessionTypes.Struct` object.
* @param session SessionTypes.Struct
* @returns `ProposalTypes.RequiredNamespaces`
*/
export const accountAndLedgerFromSession = (
session: SessionTypes.Struct,
): { network: LedgerId; account: AccountId }[] => {
const hederaNamespace = session.namespaces.hedera
if (!hederaNamespace) throw new Error('No hedera namespace found')

return hederaNamespace.accounts.map((account) => {
const [chain, network, acc] = account.split(':')
return {
network: CAIPChainIdToLedgerId(chain + ':' + network),
account: AccountId.fromString(acc),
}
})
}
Loading