Skip to content

Commit 0e1c063

Browse files
committed
feat: support for turnkey frontend wallets
1 parent 29f0574 commit 0e1c063

File tree

12 files changed

+564
-17
lines changed

12 files changed

+564
-17
lines changed

packages/sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@turnkey/core": ">=1.1.1",
7272
"@turnkey/http": ">=3.12.1",
7373
"@turnkey/sdk-server": ">=4.9.1",
74+
"@turnkey/react-wallet-kit": ">=1.1.1",
7475
"@turnkey/viem": ">=0.14.1"
7576
},
7677
"devDependencies": {

packages/sdk/src/verbs.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,11 @@ describe('Verbs SDK', () => {
273273

274274
it('should throw error for unsupported lending provider', () => {
275275
expect(() => {
276-
new Verbs(
276+
new Verbs<
277+
TestWalletProvider['providerTypes'],
278+
TestWalletProvider,
279+
'privy'
280+
>(
277281
{
278282
chains: [{ chainId: unichain.id }],
279283
lend: {
@@ -349,7 +353,11 @@ describe('Verbs SDK', () => {
349353
}
350354

351355
expect(() => {
352-
new Verbs(
356+
new Verbs<
357+
TestWalletProvider['providerTypes'],
358+
TestWalletProvider,
359+
'privy'
360+
>(
353361
{
354362
chains: [{ chainId: unichain.id }],
355363
lend: config,
@@ -384,7 +392,11 @@ describe('Verbs SDK', () => {
384392
}
385393

386394
expect(() => {
387-
new Verbs(
395+
new Verbs<
396+
TestWalletProvider['providerTypes'],
397+
TestWalletProvider,
398+
'privy'
399+
>(
388400
{
389401
chains: [{ chainId: unichain.id }],
390402
lend: config,
@@ -461,7 +473,11 @@ describe('Verbs SDK', () => {
461473

462474
describe('Unit Tests', () => {
463475
it('should list supported chain IDs', () => {
464-
const verbs = new Verbs(
476+
const verbs = new Verbs<
477+
TestWalletProvider['providerTypes'],
478+
TestWalletProvider,
479+
'privy'
480+
>(
465481
{
466482
chains: [{ chainId: unichain.id }],
467483
lend: {
@@ -713,7 +729,11 @@ describe('Verbs SDK', () => {
713729
it.runIf(externalTest())(
714730
'should handle non-existent vault gracefully',
715731
async () => {
716-
const verbs = new Verbs(
732+
const verbs = new Verbs<
733+
TestWalletProvider['providerTypes'],
734+
TestWalletProvider,
735+
'privy'
736+
>(
717737
{
718738
chains: [
719739
{
@@ -813,7 +833,11 @@ describe('Verbs SDK', () => {
813833
})
814834

815835
it.runIf(externalTest())('should get list of vaults', async () => {
816-
const verbs = new Verbs(
836+
const verbs = new Verbs<
837+
TestWalletProvider['providerTypes'],
838+
TestWalletProvider,
839+
'privy'
840+
>(
817841
{
818842
chains: [
819843
{

packages/sdk/src/verbs.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,19 @@ export class Verbs<
130130
const factory = this.hostedWalletProviderRegistry.getFactory(
131131
hostedWalletProviderConfig.type,
132132
)
133-
if (!factory.validateOptions(hostedWalletProviderConfig.config)) {
133+
const options = (
134+
'config' in hostedWalletProviderConfig
135+
? hostedWalletProviderConfig.config
136+
: undefined
137+
) as unknown
138+
if (!factory.validateOptions(options)) {
134139
throw new Error(
135140
`Invalid options for hosted wallet provider: ${hostedWalletProviderConfig.type}`,
136141
)
137142
}
138143
this.hostedWalletProvider = factory.create(
139144
{ chainManager: this.chainManager },
140-
hostedWalletProviderConfig.config,
145+
options,
141146
)
142147

143148
if (

packages/sdk/src/wallet/core/providers/hosted/types/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ export interface HostedProviderDeps {
2121
*/
2222
export type ProviderSpec<
2323
TType extends string,
24-
TConfigMap extends { [K in TType]: unknown },
24+
TConfigMap extends { [K in TType]: unknown | undefined },
2525
> = {
26-
type: TType
27-
config?: TConfigMap[TType]
28-
}
26+
[K in TType]: undefined extends TConfigMap[K]
27+
? { type: K }
28+
: { type: K; config: TConfigMap[K] }
29+
}[TType]
2930

3031
/**
3132
* Hosted wallet provider factory
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { ChainManager } from '@/services/ChainManager.js'
2+
import { HostedWalletProvider } from '@/wallet/core/providers/hosted/abstract/HostedWalletProvider.js'
3+
import type { Wallet } from '@/wallet/core/wallets/abstract/Wallet.js'
4+
import type { ReactToVerbsOptionsMap } from '@/wallet/react/providers/hosted/types/index.js'
5+
import { TurnkeyWallet } from '@/wallet/react/wallets/hosted/turnkey/TurnkeyWallet.js'
6+
7+
/**
8+
* Turnkey wallet provider implementation
9+
* @description Hosted wallet provider that wraps Turnkey's signing infrastructure
10+
* and exposes a Verbs-compatible wallet. This provider is intended for browser
11+
* environments where the Turnkey client and
12+
* organization context are provided at construction time.
13+
*/
14+
export class TurnkeyHostedWalletProvider extends HostedWalletProvider<
15+
'turnkey',
16+
ReactToVerbsOptionsMap
17+
> {
18+
/**
19+
* Create a new Turnkey wallet provider
20+
* @param client - Turnkey browser client instance
21+
* @param organizationId - Turnkey organization ID that owns the signing key
22+
* @param chainManager - Chain manager used to resolve chains and RPC transports
23+
*/
24+
constructor(chainManager: ChainManager) {
25+
super(chainManager)
26+
}
27+
28+
/**
29+
* Convert a Turnkey hosted wallet context into a Verbs wallet
30+
* @description Creates a `TurnkeyWallet` configured with the provider's Turnkey
31+
* client and organization.
32+
* @param params - Options for creating the Verbs wallet from Turnkey context
33+
* @param params.client - Turnkey client instance
34+
* @param params.organizationId - Turnkey organization ID that owns the signing key
35+
* @param params.signWith - Wallet account address, private key address, or private key ID
36+
* @param params.ethereumAddress - Ethereum address to use for this account, in the case that a private key ID is used to sign.
37+
* @returns Promise resolving to a Verbs-compatible wallet instance
38+
*/
39+
async toVerbsWallet(
40+
params: ReactToVerbsOptionsMap['turnkey'],
41+
): Promise<Wallet> {
42+
const { client, organizationId, signWith, ethereumAddress } = params
43+
return TurnkeyWallet.create({
44+
client,
45+
organizationId,
46+
signWith,
47+
ethereumAddress,
48+
chainManager: this.chainManager,
49+
})
50+
}
51+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { TurnkeySDKClientBase } from '@turnkey/react-wallet-kit'
2+
import { unichain } from 'viem/chains'
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
import type { ChainManager } from '@/services/ChainManager.js'
6+
import { MockChainManager } from '@/test/MockChainManager.js'
7+
import { TurnkeyHostedWalletProvider } from '@/wallet/react/providers/hosted/turnkey/TurnkeyHostedWalletProvider.js'
8+
import { TurnkeyWallet } from '@/wallet/react/wallets/hosted/turnkey/TurnkeyWallet.js'
9+
10+
describe('TurnkeyHostedWalletProvider', () => {
11+
const mockChainManager = new MockChainManager({
12+
supportedChains: [unichain.id],
13+
}) as unknown as ChainManager
14+
15+
it('forwards params to TurnkeyWallet.create', async () => {
16+
const turnkeyClient = {} as unknown as TurnkeySDKClientBase
17+
const provider = new TurnkeyHostedWalletProvider(mockChainManager)
18+
const spyTurnkeyWalletCreate = vi
19+
.spyOn(TurnkeyWallet, 'create')
20+
.mockResolvedValueOnce({
21+
address: '0xabc',
22+
} as unknown as TurnkeyWallet)
23+
24+
await provider.toVerbsWallet({
25+
client: turnkeyClient,
26+
organizationId: 'org_123',
27+
signWith: 'key_abc',
28+
})
29+
30+
expect(spyTurnkeyWalletCreate).toHaveBeenCalledWith(
31+
expect.objectContaining({
32+
client: turnkeyClient,
33+
organizationId: 'org_123',
34+
signWith: 'key_abc',
35+
ethereumAddress: undefined,
36+
chainManager: mockChainManager,
37+
}),
38+
)
39+
})
40+
41+
it('forwards ethereumAddress when provided', async () => {
42+
const turnkeyClient = {} as unknown as TurnkeySDKClientBase
43+
const provider = new TurnkeyHostedWalletProvider(mockChainManager)
44+
const spyTurnkeyWalletCreate = vi
45+
.spyOn(TurnkeyWallet, 'create')
46+
.mockResolvedValueOnce({
47+
address: '0xabc',
48+
} as unknown as TurnkeyWallet)
49+
50+
await provider.toVerbsWallet({
51+
client: turnkeyClient,
52+
organizationId: 'org_123',
53+
signWith: 'key_abc',
54+
ethereumAddress: '0x123',
55+
})
56+
57+
expect(spyTurnkeyWalletCreate).toHaveBeenCalledWith(
58+
expect.objectContaining({
59+
client: turnkeyClient,
60+
organizationId: 'org_123',
61+
signWith: 'key_abc',
62+
ethereumAddress: '0x123',
63+
chainManager: mockChainManager,
64+
}),
65+
)
66+
})
67+
68+
it('returns the created TurnkeyWallet instance', async () => {
69+
const turnkeyClient = {} as unknown as TurnkeySDKClientBase
70+
const provider = new TurnkeyHostedWalletProvider(mockChainManager)
71+
const fakeWallet = {
72+
address: '0xabc',
73+
} as unknown as TurnkeyWallet
74+
vi.spyOn(TurnkeyWallet, 'create').mockResolvedValueOnce(fakeWallet)
75+
76+
const verbsWallet = await provider.toVerbsWallet({
77+
client: turnkeyClient,
78+
organizationId: 'org_123',
79+
signWith: 'key_abc',
80+
})
81+
82+
expect(verbsWallet).toBe(fakeWallet)
83+
})
84+
})

packages/sdk/src/wallet/react/providers/hosted/types/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { Wallet as DynamicWallet } from '@dynamic-labs/wallet-connector-core'
22
import type { ConnectedWallet } from '@privy-io/react-auth'
3+
import type { TurnkeySDKClientBase } from '@turnkey/react-wallet-kit'
34

45
import type { HostedWalletProvidersSchema } from '@/wallet/core/providers/hosted/types/index.js'
56
import type { DynamicHostedWalletProvider } from '@/wallet/react/providers/hosted/dynamic/DynamicHostedWalletProvider.js'
67
import type { PrivyHostedWalletProvider } from '@/wallet/react/providers/hosted/privy/PrivyHostedWalletProvider.js'
8+
import type { TurnkeyHostedWalletProvider } from '@/wallet/react/providers/hosted/turnkey/TurnkeyHostedWalletProvider.js'
79

810
/**
911
* React provider type keys
@@ -25,6 +27,7 @@ export type ReactProviderTypes = keyof ReactOptionsMap &
2527
export interface ReactOptionsMap {
2628
dynamic: undefined
2729
privy: undefined
30+
turnkey: undefined
2831
}
2932

3033
/**
@@ -45,13 +48,30 @@ export type PrivyHostedWalletToVerbsWalletOptions = {
4548
connectedWallet: ConnectedWallet
4649
}
4750

51+
/**
52+
* Options for converting a Turnkey hosted wallet to a Verbs wallet
53+
* @description Parameters for converting a hosted wallet to a Verbs wallet
54+
* @property signWith This can be a wallet account address, private key address, or private key ID.
55+
* @property ethereumAddress Ethereum address to use for this account, in the case that a private key ID is used to sign.
56+
* If left undefined, `createAccount` will fetch it from the Turnkey API. We recommend setting this if you're using a passkey
57+
* client, so that your users are not prompted for a passkey signature just to fetch their address. You may leave this
58+
* undefined if using an API key client.
59+
*/
60+
export type TurnkeyHostedWalletToVerbsWalletOptions = {
61+
client: TurnkeySDKClientBase
62+
organizationId: string
63+
signWith: string
64+
ethereumAddress?: string
65+
}
66+
4867
/**
4968
* React/browser hosted wallet registry
5069
* @description Registers browser-only providers for client apps.
5170
*/
5271
export type ReactHostedProviderInstanceMap = {
5372
dynamic: DynamicHostedWalletProvider
5473
privy: PrivyHostedWalletProvider
74+
turnkey: TurnkeyHostedWalletProvider
5575
}
5676

5777
/**
@@ -61,6 +81,7 @@ export type ReactHostedProviderInstanceMap = {
6181
export type ReactToVerbsOptionsMap = {
6282
dynamic: DynamicHostedWalletToVerbsWalletOptions
6383
privy: PrivyHostedWalletToVerbsWalletOptions
84+
turnkey: TurnkeyHostedWalletToVerbsWalletOptions
6485
}
6586

6687
/**

packages/sdk/src/wallet/react/providers/registry/ReactHostedWalletProviderRegistry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HostedWalletProviderRegistry } from '@/wallet/core/providers/hosted/registry/HostedWalletProviderRegistry.js'
22
import { DynamicHostedWalletProvider } from '@/wallet/react/providers/hosted/dynamic/DynamicHostedWalletProvider.js'
33
import { PrivyHostedWalletProvider } from '@/wallet/react/providers/hosted/privy/PrivyHostedWalletProvider.js'
4+
import { TurnkeyHostedWalletProvider } from '@/wallet/react/providers/hosted/turnkey/TurnkeyHostedWalletProvider.js'
45
import type {
56
ReactHostedProviderInstanceMap,
67
ReactOptionsMap,
@@ -42,5 +43,15 @@ export class ReactHostedWalletProviderRegistry extends HostedWalletProviderRegis
4243
return new PrivyHostedWalletProvider(chainManager)
4344
},
4445
})
46+
47+
this.register<'turnkey'>({
48+
type: 'turnkey',
49+
validateOptions(_options): _options is ReactOptionsMap['turnkey'] {
50+
return true
51+
},
52+
create({ chainManager }, _options) {
53+
return new TurnkeyHostedWalletProvider(chainManager)
54+
},
55+
})
4556
}
4657
}

packages/sdk/src/wallet/react/providers/registry/__tests__/ReactHostedWalletProviderRegistry.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ChainManager } from '@/services/ChainManager.js'
55
import { MockChainManager } from '@/test/MockChainManager.js'
66
import { DynamicHostedWalletProvider } from '@/wallet/react/providers/hosted/dynamic/DynamicHostedWalletProvider.js'
77
import { PrivyHostedWalletProvider } from '@/wallet/react/providers/hosted/privy/PrivyHostedWalletProvider.js'
8+
import { TurnkeyHostedWalletProvider } from '@/wallet/react/providers/hosted/turnkey/TurnkeyHostedWalletProvider.js'
89
import type { ReactOptionsMap } from '@/wallet/react/providers/hosted/types/index.js'
910
import { ReactHostedWalletProviderRegistry } from '@/wallet/react/providers/registry/ReactHostedWalletProviderRegistry.js'
1011

@@ -84,6 +85,28 @@ describe('ReactHostedWalletProviderRegistry', () => {
8485
expect(provider).toBeInstanceOf(PrivyHostedWalletProvider)
8586
})
8687

88+
it('returns turnkey factory and validates options', () => {
89+
const registry = new ReactHostedWalletProviderRegistry()
90+
const factory = registry.getFactory('turnkey')
91+
92+
expect(factory.type).toBe('turnkey')
93+
expect(
94+
factory.validateOptions?.(undefined as ReactOptionsMap['turnkey']),
95+
).toBe(true)
96+
})
97+
98+
it('creates a TurnkeyHostedWalletProvider instance', () => {
99+
const registry = new ReactHostedWalletProviderRegistry()
100+
const factory = registry.getFactory('turnkey')
101+
102+
const provider = factory.create(
103+
{ chainManager: mockChainManager },
104+
undefined as ReactOptionsMap['turnkey'],
105+
)
106+
107+
expect(provider).toBeInstanceOf(TurnkeyHostedWalletProvider)
108+
})
109+
87110
it('throws for unknown provider type', () => {
88111
const registry = new ReactHostedWalletProviderRegistry()
89112
// @ts-expect-error: testing runtime error for unknown type

0 commit comments

Comments
 (0)