Skip to content
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
6 changes: 5 additions & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@
"@dynamic-labs/ethereum": ">=4.31.4",
"@dynamic-labs/wallet-connector-core": ">=4.31.4",
"@dynamic-labs/waas-evm": ">=4.31.4",
"@privy-io/server-auth": ">=1.28.0"
"@privy-io/server-auth": ">=1.28.0",
"@turnkey/core": ">=1.1.1",
"@turnkey/http": ">=3.12.1",
"@turnkey/sdk-server": ">=4.9.1",
"@turnkey/viem": ">=0.14.1"
},
"devDependencies": {
"@types/node": "^18",
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/nodeVerbsFactory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { VerbsConfig } from '@/types/verbs.js'
import { Verbs } from '@/verbs.js'
import type { NodeHostedProviderType } from '@/wallet/providers/hostedProvider.types.js'
import { NodeHostedWalletProviderRegistry } from '@/wallet/providers/NodeHostedWalletProviderRegistry.js'

/**
Expand All @@ -11,8 +12,10 @@ import { NodeHostedWalletProviderRegistry } from '@/wallet/providers/NodeHostedW
* @param config Verbs configuration
* @returns Verbs instance using the NodeHostedWalletProviderRegistry
*/
export function createVerbs(config: VerbsConfig<'privy'>) {
return new Verbs(config, {
export function createVerbs<T extends NodeHostedProviderType>(
config: VerbsConfig<T>,
) {
return new Verbs<T>(config, {
hostedWalletProviderRegistry: new NodeHostedWalletProviderRegistry(),
})
}
9 changes: 9 additions & 0 deletions packages/sdk/src/types/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,12 @@ export type PrivyHostedWalletToVerbsWalletOptions = {
export type DynamicHostedWalletToVerbsWalletOptions = {
wallet: DynamicWallet
}

/**
* Options for converting a Turnkey hosted wallet to a Verbs wallet
* @description Parameters for converting a hosted wallet to a Verbs wallet
*/
export type TurnkeyHostedWalletToVerbsWalletOptions = {
signWith: string
ethereumAddress?: string
}
104 changes: 104 additions & 0 deletions packages/sdk/src/wallet/TurnkeyWallet.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { TurnkeySDKClientBase } from '@turnkey/core'
import type { TurnkeyClient as TurnkeyHttpClient } from '@turnkey/http'
import type { TurnkeyServerClient } from '@turnkey/sdk-server'
import { createAccount } from '@turnkey/viem'
import type { LocalAccount, WalletClient } from 'viem'
import { createWalletClient } from 'viem'
import { unichain } from 'viem/chains'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { ChainManager } from '@/services/ChainManager.js'
import { MockChainManager } from '@/test/MockChainManager.js'
import { getRandomAddress } from '@/test/utils.js'
import { TurnkeyWallet } from '@/wallet/TurnkeyWallet.js'

vi.mock('viem', async () => ({
// @ts-ignore - importActual returns unknown
...(await vi.importActual('viem')),
createWalletClient: vi.fn(),
}))

vi.mock('@turnkey/viem', async () => ({
createAccount: vi.fn(),
}))

const mockAddress = getRandomAddress()
const mockChainManager = new MockChainManager({
supportedChains: [unichain.id],
}) as unknown as ChainManager

function createMockTurnkeyClient():
| TurnkeyHttpClient
| TurnkeyServerClient
| TurnkeySDKClientBase {
return {
// minimal shape for typing; createAccount uses this via @turnkey/viem
} as unknown as TurnkeyHttpClient
}

describe('TurnkeyWallet', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('should initialize signer and address from Turnkey account', async () => {
const mockLocalAccount = { address: mockAddress } as unknown as LocalAccount
vi.mocked(createAccount).mockResolvedValue(mockLocalAccount)

const wallet = await TurnkeyWallet.create({
client: createMockTurnkeyClient(),
organizationId: 'org_123',
signWith: 'key_abc',
chainManager: mockChainManager,
})

expect(wallet.address).toBe(mockAddress)
expect(wallet.signer).toBe(mockLocalAccount)
expect(createAccount).toHaveBeenCalledOnce()
const args = vi.mocked(createAccount).mock.calls[0][0]
expect(args.client).toEqual(createMockTurnkeyClient())
expect(args.organizationId).toBe('org_123')
expect(args.signWith).toBe('key_abc')
expect(args.ethereumAddress).toBeUndefined()
})

it('takes ethereumAddress', async () => {
const mockLocalAccount = { address: mockAddress } as unknown as LocalAccount
vi.mocked(createAccount).mockResolvedValue(mockLocalAccount)

await TurnkeyWallet.create({
client: createMockTurnkeyClient(),
organizationId: 'org_123',
signWith: 'key_abc',
ethereumAddress: '0x123',
chainManager: mockChainManager,
})

const args = vi.mocked(createAccount).mock.calls[0][0]
expect(args.ethereumAddress).toBe('0x123')
})

it('should create a wallet client with correct configuration', async () => {
const mockLocalAccount = { address: mockAddress } as unknown as LocalAccount
vi.mocked(createAccount).mockResolvedValue(mockLocalAccount)
const wallet = await TurnkeyWallet.create({
client: createMockTurnkeyClient(),
organizationId: 'org_123',
signWith: 'key_abc',
chainManager: mockChainManager,
})
const mockWalletClient = {
account: mockLocalAccount,
address: mockAddress,
} as unknown as WalletClient
vi.mocked(createWalletClient).mockResolvedValue(mockWalletClient)

const walletClient = await wallet.walletClient(unichain.id)

expect(createWalletClient).toHaveBeenCalledOnce()
const args = vi.mocked(createWalletClient).mock.calls[0][0]
expect(args.account).toBe(mockLocalAccount)
expect(args.chain).toBe(mockChainManager.getChain(unichain.id))
expect(walletClient).toBe(mockWalletClient)
})
})
94 changes: 94 additions & 0 deletions packages/sdk/src/wallet/TurnkeyWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { TurnkeySDKClientBase } from '@turnkey/core'
import type { TurnkeyClient } from '@turnkey/http'
import type { TurnkeyServerClient } from '@turnkey/sdk-server'
import { createAccount } from '@turnkey/viem'
import type { Address, LocalAccount, WalletClient } from 'viem'
import { createWalletClient, fallback, http } from 'viem'

import type { SupportedChainId } from '@/constants/supportedChains.js'
import type { ChainManager } from '@/services/ChainManager.js'
import { Wallet } from '@/wallet/base/Wallet.js'

/**
* Turnkey wallet implementation
* @description Wallet implementation using Turnkey service
*/
export class TurnkeyWallet extends Wallet {
public address!: Address
public signer!: LocalAccount
/**
* Turnkey client instance (HTTP, server, or core SDK base)
*/
private readonly client:
| TurnkeyClient
| TurnkeyServerClient
| TurnkeySDKClientBase
/**
* Turnkey organization ID that owns the signing key
*/
private readonly organizationId: string
Copy link
Collaborator

Choose a reason for hiding this comment

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

❓ Will this ever change from wallet to wallet? If not, should it live at an abstraction above the wallet instance, like the VerbsConfig?

Copy link
Contributor Author

@tremarkley tremarkley Sep 25, 2025

Choose a reason for hiding this comment

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

yeah, so looking at the turnkey docs, it is possible that all wallets could be under the same organizationId, but they also have support for each wallet being in its own sub-organization, which would mean a different organizationId per wallet. Here is more info: https://docs.turnkey.com/embedded-wallets/sub-organizations-as-wallets.

Given this I think it makes sense to abstract this at the wallet-level for now and we can always pull it into a higher level later if we find this is a big point of friction, but at least this design supports both use-cases (ie wallets as sub-organizations or all wallets under same organization)

/**
* This can be a wallet account address, private key address, or private key ID.
*/
private readonly signWith: string
/**
* Ethereum address to use for this account, in the case that a private key ID is used to sign.
* If left undefined, `createAccount` will fetch it from the Turnkey API.
* We recommend setting this if you're using a passkey client, so that your users are not prompted for a passkey signature just to fetch their address.
* You may leave this undefined if using an API key client.
*/
private readonly ethereumAddress?: string

private constructor(params: {
chainManager: ChainManager
client: TurnkeyClient | TurnkeyServerClient | TurnkeySDKClientBase
organizationId: string
signWith: string
ethereumAddress?: string
}) {
const { chainManager, client, organizationId, signWith, ethereumAddress } =
params
super(chainManager)
Copy link
Collaborator

Choose a reason for hiding this comment

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

🤔 I know this isn't new to this class, but I've wondered if the ChainManager needs to be passed through every instance of everything, or if it can live at an abstraction above? Thinking out loud, I'm not sure what that would look like, but like supported assets and supported networks, it's not something that will change from wallet to wallet.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

even though ChainManager doesnt change from wallet to wallet, it does change for each Verbs instance and the wallet needs a way to access it. Right now we've opted for accessing it through dependency injection, which has it's tradeoffs, but the only other way we could potentially access it is through a global variable, which might make things more difficult to test and harder to trace. Let me know if there is another way you were thinking though or if you think a global variable would be preferred here

Copy link
Collaborator

Choose a reason for hiding this comment

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

Having thought through it a bit more, I think this is totally fine. Global state gets dangerous and passing around a single dependency is very manageable.

I’m imaging a future where additional context providers are added to verbs that allow the dev to overwrite functionality like:

  • ChainExplorerProvider: overwrite all receipt urls with blockscout or etherscan
  • TransactionSimulationProvider: overwrite all wallet.action.quote functions with tenderly or alchemy simulations

If we get to that world, we might pull these dependencies into a shared context provider registry and pass that single object around, but that's well into the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes sense! if we get to a point where providers are coupled, I could see combining them making sense

this.client = client
this.organizationId = organizationId
this.signWith = signWith
this.ethereumAddress = ethereumAddress
}

static async create(params: {
chainManager: ChainManager
client: TurnkeyClient | TurnkeyServerClient | TurnkeySDKClientBase
organizationId: string
signWith: string
ethereumAddress?: string
}): Promise<TurnkeyWallet> {
const wallet = new TurnkeyWallet(params)
await wallet.initialize()
return wallet
}

async walletClient(chainId: SupportedChainId): Promise<WalletClient> {
const rpcUrls = this.chainManager.getRpcUrls(chainId)
return createWalletClient({
account: this.signer,
chain: this.chainManager.getChain(chainId),
transport: rpcUrls?.length
? fallback(rpcUrls.map((rpcUrl) => http(rpcUrl)))
: http(),
})
}

protected async performInitialization() {
this.signer = await this.createAccount()
this.address = this.signer.address
}

private async createAccount(): Promise<LocalAccount> {
return createAccount({
client: this.client,
organizationId: this.organizationId,
signWith: this.signWith,
ethereumAddress: this.ethereumAddress,
})
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PrivyClient } from '@privy-io/server-auth'
import type { TurnkeyClient } from '@turnkey/http'
import { unichain } from 'viem/chains'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

Expand All @@ -8,12 +9,14 @@ import { createMockPrivyClient } from '@/test/MockPrivyClient.js'
import type { PrivyOptions } from '@/wallet/providers/hostedProvider.types.js'
import { NodeHostedWalletProviderRegistry } from '@/wallet/providers/NodeHostedWalletProviderRegistry.js'
import { PrivyHostedWalletProvider } from '@/wallet/providers/PrivyHostedWalletProvider.js'
import { TurnkeyHostedWalletProvider } from '@/wallet/providers/TurnkeyHostedWalletProvider.js'

describe('NodeHostedWalletProviderRegistry', () => {
const mockChainManager = new MockChainManager({
supportedChains: [unichain.id],
}) as unknown as ChainManager
let mockPrivyClient: PrivyClient
const mockTurnkeyClient = {} as unknown as TurnkeyClient

beforeEach(() => {
mockPrivyClient = createMockPrivyClient('test-app-id', 'test-app-secret')
Expand Down Expand Up @@ -46,6 +49,38 @@ describe('NodeHostedWalletProviderRegistry', () => {
expect(provider).toBeInstanceOf(PrivyHostedWalletProvider)
})

it('returns turnkey factory and validates options', () => {
const registry = new NodeHostedWalletProviderRegistry()
const factory = registry.getFactory('turnkey')

expect(factory.type).toBe('turnkey')
expect(
factory.validateOptions?.({
client: mockTurnkeyClient,
organizationId: 'org_123',
}),
).toBe(true)
// Invalid shape should not pass validation
expect(factory.validateOptions?.({})).toBe(false)
expect(factory.validateOptions?.({ client: mockTurnkeyClient })).toBe(false)
expect(factory.validateOptions?.({ organizationId: 'org_123' })).toBe(false)
})

it('creates a TurnkeyHostedWalletProvider instance', () => {
const registry = new NodeHostedWalletProviderRegistry()
const factory = registry.getFactory('turnkey')

const provider = factory.create(
{ chainManager: mockChainManager },
{
client: mockTurnkeyClient,
organizationId: 'org_123',
},
)

expect(provider).toBeInstanceOf(TurnkeyHostedWalletProvider)
})

it('throws for unknown provider type', () => {
const registry = new NodeHostedWalletProviderRegistry()
// @ts-expect-error: testing runtime error for unknown type
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { HostedWalletProviderRegistry } from '@/wallet/providers/base/HostedWalletProviderRegistry.js'
import type { PrivyOptions } from '@/wallet/providers/hostedProvider.types.js'
import type {
PrivyOptions,
TurnkeyOptions,
} from '@/wallet/providers/hostedProvider.types.js'
import { PrivyHostedWalletProvider } from '@/wallet/providers/PrivyHostedWalletProvider.js'
import { TurnkeyHostedWalletProvider } from '@/wallet/providers/TurnkeyHostedWalletProvider.js'

/**
* Node environment hosted wallet registry.
Expand All @@ -18,5 +22,20 @@ export class NodeHostedWalletProviderRegistry extends HostedWalletProviderRegist
return new PrivyHostedWalletProvider(options.privyClient, chainManager)
},
})

this.register<'turnkey'>({
type: 'turnkey',
validateOptions(options): options is TurnkeyOptions {
const o = options as TurnkeyOptions
return Boolean(o?.client) && typeof o?.organizationId === 'string'
},
create({ chainManager }, options) {
return new TurnkeyHostedWalletProvider(
options.client,
options.organizationId,
chainManager,
)
},
})
}
}
Loading