-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add support for Turnkey server wallets #137
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| }) | ||
| }) |
| 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 | ||
| /** | ||
| * 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. even though There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 differentorganizationIdper 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)