Skip to content

Commit cbde13e

Browse files
committed
feat: Turnkey server side wallets
1 parent e876a00 commit cbde13e

File tree

9 files changed

+859
-8
lines changed

9 files changed

+859
-8
lines changed

packages/sdk/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@
6666
"@dynamic-labs/ethereum": ">=4.31.4",
6767
"@dynamic-labs/wallet-connector-core": ">=4.31.4",
6868
"@dynamic-labs/waas-evm": ">=4.31.4",
69-
"@privy-io/server-auth": ">=1.28.0"
69+
"@privy-io/server-auth": ">=1.28.0",
70+
"@turnkey/core": ">=1.1.1",
71+
"@turnkey/http": ">=3.12.1",
72+
"@turnkey/sdk-server": ">=4.9.1",
73+
"@turnkey/viem": ">=0.14.1"
7074
},
7175
"devDependencies": {
7276
"@types/node": "^18",

packages/sdk/src/nodeVerbsFactory.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { VerbsConfig } from '@/types/verbs.js'
22
import { Verbs } from '@/verbs.js'
3+
import type { NodeHostedProviderType } from '@/wallet/providers/hostedProvider.types.js'
34
import { NodeHostedWalletProviderRegistry } from '@/wallet/providers/NodeHostedWalletProviderRegistry.js'
45

56
/**
@@ -11,8 +12,10 @@ import { NodeHostedWalletProviderRegistry } from '@/wallet/providers/NodeHostedW
1112
* @param config Verbs configuration
1213
* @returns Verbs instance using the NodeHostedWalletProviderRegistry
1314
*/
14-
export function createVerbs(config: VerbsConfig<'privy'>) {
15-
return new Verbs(config, {
15+
export function createVerbs<T extends NodeHostedProviderType>(
16+
config: VerbsConfig<T>,
17+
) {
18+
return new Verbs<T>(config, {
1619
hostedWalletProviderRegistry: new NodeHostedWalletProviderRegistry(),
1720
})
1821
}

packages/sdk/src/types/wallet.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,12 @@ export type PrivyHostedWalletToVerbsWalletOptions = {
4040
export type DynamicHostedWalletToVerbsWalletOptions = {
4141
wallet: DynamicWallet
4242
}
43+
44+
/**
45+
* Options for converting a Turnkey hosted wallet to a Verbs wallet
46+
* @description Parameters for converting a hosted wallet to a Verbs wallet
47+
*/
48+
export type TurnkeyHostedWalletToVerbsWalletOptions = {
49+
signWith: string
50+
ethereumAddress?: string
51+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { TurnkeySDKClientBase } from '@turnkey/core'
2+
import type { TurnkeyClient as TurnkeyHttpClient } from '@turnkey/http'
3+
import type { TurnkeyServerClient } from '@turnkey/sdk-server'
4+
import { createAccount } from '@turnkey/viem'
5+
import type { LocalAccount, WalletClient } from 'viem'
6+
import { createWalletClient } from 'viem'
7+
import { unichain } from 'viem/chains'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
import type { ChainManager } from '@/services/ChainManager.js'
11+
import { MockChainManager } from '@/test/MockChainManager.js'
12+
import { getRandomAddress } from '@/test/utils.js'
13+
import { TurnkeyWallet } from '@/wallet/TurnkeyWallet.js'
14+
15+
vi.mock('viem', async () => ({
16+
// @ts-ignore - importActual returns unknown
17+
...(await vi.importActual('viem')),
18+
createWalletClient: vi.fn(),
19+
}))
20+
21+
vi.mock('@turnkey/viem', async () => ({
22+
createAccount: vi.fn(),
23+
}))
24+
25+
const mockAddress = getRandomAddress()
26+
const mockChainManager = new MockChainManager({
27+
supportedChains: [unichain.id],
28+
}) as unknown as ChainManager
29+
30+
function createMockTurnkeyClient():
31+
| TurnkeyHttpClient
32+
| TurnkeyServerClient
33+
| TurnkeySDKClientBase {
34+
return {
35+
// minimal shape for typing; createAccount uses this via @turnkey/viem
36+
} as unknown as TurnkeyHttpClient
37+
}
38+
39+
describe('TurnkeyWallet', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
})
43+
44+
it('should initialize signer and address from Turnkey account', async () => {
45+
const mockLocalAccount = { address: mockAddress } as unknown as LocalAccount
46+
vi.mocked(createAccount).mockResolvedValue(mockLocalAccount)
47+
48+
const wallet = await TurnkeyWallet.create({
49+
client: createMockTurnkeyClient(),
50+
organizationId: 'org_123',
51+
signWith: 'key_abc',
52+
chainManager: mockChainManager,
53+
})
54+
55+
expect(wallet.address).toBe(mockAddress)
56+
expect(wallet.signer).toBe(mockLocalAccount)
57+
expect(createAccount).toHaveBeenCalledOnce()
58+
const args = vi.mocked(createAccount).mock.calls[0][0]
59+
expect(args.client).toEqual(createMockTurnkeyClient())
60+
expect(args.organizationId).toBe('org_123')
61+
expect(args.signWith).toBe('key_abc')
62+
expect(args.ethereumAddress).toBeUndefined()
63+
})
64+
65+
it('takes ethereumAddress', async () => {
66+
const mockLocalAccount = { address: mockAddress } as unknown as LocalAccount
67+
vi.mocked(createAccount).mockResolvedValue(mockLocalAccount)
68+
69+
await TurnkeyWallet.create({
70+
client: createMockTurnkeyClient(),
71+
organizationId: 'org_123',
72+
signWith: 'key_abc',
73+
ethereumAddress: '0x123',
74+
chainManager: mockChainManager,
75+
})
76+
77+
const args = vi.mocked(createAccount).mock.calls[0][0]
78+
expect(args.ethereumAddress).toBe('0x123')
79+
})
80+
81+
it('should create a wallet client with correct configuration', async () => {
82+
const mockLocalAccount = { address: mockAddress } as unknown as LocalAccount
83+
vi.mocked(createAccount).mockResolvedValue(mockLocalAccount)
84+
const wallet = await TurnkeyWallet.create({
85+
client: createMockTurnkeyClient(),
86+
organizationId: 'org_123',
87+
signWith: 'key_abc',
88+
chainManager: mockChainManager,
89+
})
90+
const mockWalletClient = {
91+
account: mockLocalAccount,
92+
address: mockAddress,
93+
} as unknown as WalletClient
94+
vi.mocked(createWalletClient).mockResolvedValue(mockWalletClient)
95+
96+
const walletClient = await wallet.walletClient(unichain.id)
97+
98+
expect(createWalletClient).toHaveBeenCalledOnce()
99+
const args = vi.mocked(createWalletClient).mock.calls[0][0]
100+
expect(args.account).toBe(mockLocalAccount)
101+
expect(args.chain).toBe(mockChainManager.getChain(unichain.id))
102+
expect(walletClient).toBe(mockWalletClient)
103+
})
104+
})
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { TurnkeySDKClientBase } from '@turnkey/core'
2+
import type { TurnkeyClient } from '@turnkey/http'
3+
import type { TurnkeyServerClient } from '@turnkey/sdk-server'
4+
import { createAccount } from '@turnkey/viem'
5+
import type { Address, LocalAccount, WalletClient } from 'viem'
6+
import { createWalletClient, fallback, http } from 'viem'
7+
8+
import type { SupportedChainId } from '@/constants/supportedChains.js'
9+
import type { ChainManager } from '@/services/ChainManager.js'
10+
import { Wallet } from '@/wallet/base/Wallet.js'
11+
12+
/**
13+
* Turnkey wallet implementation
14+
* @description Wallet implementation using Turnkey service
15+
*/
16+
export class TurnkeyWallet extends Wallet {
17+
public address!: Address
18+
public signer!: LocalAccount
19+
/**
20+
* Turnkey client instance (HTTP, server, or core SDK base)
21+
*/
22+
private readonly client:
23+
| TurnkeyClient
24+
| TurnkeyServerClient
25+
| TurnkeySDKClientBase
26+
/**
27+
* Turnkey organization ID that owns the signing key
28+
*/
29+
private readonly organizationId: string
30+
/**
31+
* This can be a wallet account address, private key address, or private key ID.
32+
*/
33+
private readonly signWith: string
34+
/**
35+
* Ethereum address to use for this account, in the case that a private key ID is used to sign.
36+
* If left undefined, `createAccount` will fetch it from the Turnkey API.
37+
* 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.
38+
* You may leave this undefined if using an API key client.
39+
*/
40+
private readonly ethereumAddress?: string
41+
42+
private constructor(params: {
43+
chainManager: ChainManager
44+
client: TurnkeyClient | TurnkeyServerClient | TurnkeySDKClientBase
45+
organizationId: string
46+
signWith: string
47+
ethereumAddress?: string
48+
}) {
49+
const { chainManager, client, organizationId, signWith, ethereumAddress } =
50+
params
51+
super(chainManager)
52+
this.client = client
53+
this.organizationId = organizationId
54+
this.signWith = signWith
55+
this.ethereumAddress = ethereumAddress
56+
}
57+
58+
static async create(params: {
59+
chainManager: ChainManager
60+
client: TurnkeyClient | TurnkeyServerClient | TurnkeySDKClientBase
61+
organizationId: string
62+
signWith: string
63+
ethereumAddress?: string
64+
}): Promise<TurnkeyWallet> {
65+
const wallet = new TurnkeyWallet(params)
66+
await wallet.initialize()
67+
return wallet
68+
}
69+
70+
async walletClient(chainId: SupportedChainId): Promise<WalletClient> {
71+
const rpcUrls = this.chainManager.getRpcUrls(chainId)
72+
return createWalletClient({
73+
account: this.signer,
74+
chain: this.chainManager.getChain(chainId),
75+
transport: rpcUrls?.length
76+
? fallback(rpcUrls.map((rpcUrl) => http(rpcUrl)))
77+
: http(),
78+
})
79+
}
80+
81+
protected async performInitialization() {
82+
this.signer = await this.createAccount()
83+
this.address = this.signer.address
84+
}
85+
86+
private async createAccount(): Promise<LocalAccount> {
87+
return createAccount({
88+
client: this.client,
89+
organizationId: this.organizationId,
90+
signWith: this.signWith,
91+
ethereumAddress: this.ethereumAddress,
92+
})
93+
}
94+
}

packages/sdk/src/wallet/providers/NodeHostedWalletProviderRegistry.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { HostedWalletProviderRegistry } from '@/wallet/providers/base/HostedWalletProviderRegistry.js'
2-
import type { PrivyOptions } from '@/wallet/providers/hostedProvider.types.js'
2+
import type {
3+
PrivyOptions,
4+
TurnkeyOptions,
5+
} from '@/wallet/providers/hostedProvider.types.js'
36
import { PrivyHostedWalletProvider } from '@/wallet/providers/PrivyHostedWalletProvider.js'
7+
import { TurnkeyHostedWalletProvider } from '@/wallet/providers/TurnkeyHostedWalletProvider.js'
48

59
/**
610
* Node environment hosted wallet registry.
@@ -18,5 +22,20 @@ export class NodeHostedWalletProviderRegistry extends HostedWalletProviderRegist
1822
return new PrivyHostedWalletProvider(options.privyClient, chainManager)
1923
},
2024
})
25+
26+
this.register<'turnkey'>({
27+
type: 'turnkey',
28+
validateOptions(options): options is TurnkeyOptions {
29+
const o = options as TurnkeyOptions
30+
return Boolean(o?.client) && typeof o?.organizationId === 'string'
31+
},
32+
create({ chainManager }, options) {
33+
return new TurnkeyHostedWalletProvider(
34+
options.client,
35+
options.organizationId,
36+
chainManager,
37+
)
38+
},
39+
})
2140
}
2241
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { TurnkeySDKClientBase } from '@turnkey/core'
2+
import type { TurnkeyClient as TurnkeyHttpClient } from '@turnkey/http'
3+
import type { TurnkeyServerClient } from '@turnkey/sdk-server'
4+
5+
import type { ChainManager } from '@/services/ChainManager.js'
6+
import type { TurnkeyHostedWalletToVerbsWalletOptions } from '@/types/wallet.js'
7+
import type { Wallet } from '@/wallet/base/Wallet.js'
8+
import { HostedWalletProvider } from '@/wallet/providers/base/HostedWalletProvider.js'
9+
import { TurnkeyWallet } from '@/wallet/TurnkeyWallet.js'
10+
11+
/**
12+
* Turnkey wallet provider implementation
13+
* @description Hosted wallet provider that wraps Turnkey's signing infrastructure
14+
* and exposes a Verbs-compatible wallet. This provider is intended for Node
15+
* environments where the Turnkey client (HTTP, server, or core SDK) and
16+
* organization context are provided at construction time.
17+
*/
18+
export class TurnkeyHostedWalletProvider extends HostedWalletProvider<'turnkey'> {
19+
/**
20+
* Create a new Turnkey wallet provider
21+
* @param client - Turnkey client instance (HTTP, server, or core SDK base)
22+
* @param organizationId - Turnkey organization ID that owns the signing key
23+
* @param chainManager - Chain manager used to resolve chains and RPC transports
24+
*/
25+
constructor(
26+
private readonly client:
27+
| TurnkeyHttpClient
28+
| TurnkeyServerClient
29+
| TurnkeySDKClientBase,
30+
private readonly organizationId: string,
31+
chainManager: ChainManager,
32+
) {
33+
super(chainManager)
34+
}
35+
36+
/**
37+
* Convert a Turnkey hosted wallet context into a Verbs wallet
38+
* @description Creates a `TurnkeyWallet` configured with the provider's Turnkey
39+
* client and organization.
40+
* @param params - Options for creating the Verbs wallet from Turnkey context
41+
* @param params.signWith - Wallet account address, private key address, or private key ID
42+
* @param params.ethereumAddress - Ethereum address to use for this account, in the case that a private key ID is used to sign.
43+
* @returns Promise resolving to a Verbs-compatible wallet instance
44+
*/
45+
async toVerbsWallet(
46+
params: TurnkeyHostedWalletToVerbsWalletOptions,
47+
): Promise<Wallet> {
48+
return TurnkeyWallet.create({
49+
client: this.client,
50+
organizationId: this.organizationId,
51+
signWith: params.signWith,
52+
ethereumAddress: params.ethereumAddress,
53+
chainManager: this.chainManager,
54+
})
55+
}
56+
}

packages/sdk/src/wallet/providers/hostedProvider.types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,59 @@
11
import type { PrivyClient } from '@privy-io/server-auth'
2+
import type { TurnkeySDKClientBase } from '@turnkey/core'
3+
import type { TurnkeyClient as TurnkeyHttpClient } from '@turnkey/http'
4+
import type { TurnkeyServerClient } from '@turnkey/sdk-server'
25

36
import type { ChainManager } from '@/services/ChainManager.js'
47
import type {
58
DynamicHostedWalletToVerbsWalletOptions,
69
PrivyHostedWalletToVerbsWalletOptions,
10+
TurnkeyHostedWalletToVerbsWalletOptions,
711
} from '@/types/wallet.js'
812
import type { DynamicHostedWalletProvider } from '@/wallet/providers/DynamicHostedWalletProvider.js'
913
import type { PrivyHostedWalletProvider } from '@/wallet/providers/PrivyHostedWalletProvider.js'
14+
import type { TurnkeyHostedWalletProvider } from '@/wallet/providers/TurnkeyHostedWalletProvider.js'
1015

1116
export interface PrivyOptions {
1217
privyClient: PrivyClient
1318
}
1419

20+
export interface TurnkeyOptions {
21+
/**
22+
* Turnkey client instance (HTTP, server, or core SDK base)
23+
*/
24+
client: TurnkeyHttpClient | TurnkeyServerClient | TurnkeySDKClientBase
25+
/**
26+
* Turnkey organization ID that owns the signing key
27+
*/
28+
organizationId: string
29+
}
30+
1531
export type DynamicOptions = undefined
1632
export interface HostedProviderConfigMap {
1733
privy: PrivyOptions
1834
dynamic: DynamicOptions
35+
turnkey: TurnkeyOptions
1936
}
2037

2138
export interface HostedProviderInstanceMap {
2239
privy: PrivyHostedWalletProvider
2340
dynamic: DynamicHostedWalletProvider
41+
turnkey: TurnkeyHostedWalletProvider
2442
}
2543

2644
export interface HostedWalletToVerbsOptionsMap {
2745
privy: PrivyHostedWalletToVerbsWalletOptions
2846
dynamic: DynamicHostedWalletToVerbsWalletOptions
47+
turnkey: TurnkeyHostedWalletToVerbsWalletOptions
2948
}
3049

3150
export type HostedProviderType = keyof HostedProviderConfigMap
3251

52+
export type NodeHostedProviderType = Extract<
53+
HostedProviderType,
54+
'privy' | 'turnkey'
55+
>
56+
3357
export type HostedWalletToVerbsType = keyof HostedWalletToVerbsOptionsMap
3458

3559
export interface HostedProviderDeps {

0 commit comments

Comments
 (0)