Skip to content
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

feat(PSDK-670): Support external wallet imports, wallet imports from CDP Python SDK #345

Closed
Closed
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
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
maxWorkers: 1,
coverageThreshold: {
"./src/coinbase/**": {
branches: 75,
branches: 74,
functions: 85,
statements: 85,
lines: 85,
Expand Down
46 changes: 38 additions & 8 deletions src/coinbase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export type WalletAPIClient = {
* List wallets belonging to the user.
*
* @param limit - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.
* @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param page - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param options - Override http request option.
* @throws {APIError} If the request fails.
* @throws {RequiredError} If the required parameter is not provided.
Expand Down Expand Up @@ -358,7 +358,7 @@ export type AddressAPIClient = {
* @param walletId - The ID of the wallet the address belongs to.
* @param addressId - The onchain address of the address to sign the payload with.
* @param limit - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.
* @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param page - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param options - Axios request options.
* @throws {APIError} If the request fails.
*/
Expand All @@ -380,7 +380,7 @@ export type ExternalAddressAPIClient = {
*
* @param networkId - The ID of the blockchain network
* @param addressId - The ID of the address to fetch the balance for
* @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param page - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param options - Override http request option.
* @throws {APIError} If the request fails.
*/
Expand Down Expand Up @@ -812,14 +812,43 @@ export enum FundOperationStatus {
}

/**
* The Wallet Data type definition.
* The Wallet Data type definition in camelCase format.
* The data required to recreate a Wallet.
*/
export type WalletData = {
walletId: string;
seed: string;
};

/**
* The Wallet Data type definition in snake_case format.
* The data required to recreate a Wallet.
*/
export type WalletDataSnake = {
wallet_id: string;
seed: string;
};

/**
* Type guard to check if data matches the snake_case WalletDataSnake format.
*
* @param data - The data to check
* @returns True if data matches WalletDataSnake format
*/
export function isWalletDataSnake(data: unknown): data is WalletDataSnake {
return typeof data === "object" && data !== null && "wallet_id" in data && "seed" in data;
}

/**
* Type guard to check if data matches the camelCase WalletData format.
*
* @param data - The data to check
* @returns True if data matches WalletData format
*/
export function isWalletData(data: unknown): data is WalletData {
return typeof data === "object" && data !== null && "walletId" in data && "seed" in data;
}

/**
* The Seed Data type definition.
*/
Expand Down Expand Up @@ -852,6 +881,7 @@ export enum ServerSignerStatus {
* Options for creating a Wallet.
*/
export type WalletCreateOptions = {
seed?: string;
networkId?: string;
timeoutSeconds?: number;
intervalSeconds?: number;
Expand Down Expand Up @@ -1145,7 +1175,7 @@ export interface WebhookApiClient {
*
* @summary List webhooks
* @param {number} [limit] - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.
* @param {string} [page] - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param {string} [page] - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param {*} [options] - Override http request option.
* @throws {RequiredError}
*/
Expand Down Expand Up @@ -1180,7 +1210,7 @@ export interface BalanceHistoryApiClient {
* @param addressId - The ID of the address to fetch the historical balance for.
* @param assetId - The symbol of the asset to fetch the historical balance for.
* @param limit - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.
* @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param page - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param options - Override http request option.
* @throws {RequiredError}
*/
Expand All @@ -1202,7 +1232,7 @@ export interface TransactionHistoryApiClient {
* @param networkId - The ID of the blockchain network
* @param addressId - The ID of the address to fetch transactions for.
* @param limit - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.
* @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param page - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param options - Override http request option.
* @throws {RequiredError}
*/
Expand Down Expand Up @@ -1444,7 +1474,7 @@ export interface FundOperationApiClient {
* @param walletId - The ID of the wallet the address belongs to.
* @param addressId - The ID of the address to list fund operations for.
* @param limit - A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.
* @param page - A cursor for pagination across multiple pages of results. Don\'t include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param page - A cursor for pagination across multiple pages of results. Don't include this parameter on the first call. Use the next_page value returned in a previous response to request subsequent results.
* @param options - Axios request options
* @throws {APIError} If the request fails
*/
Expand Down
114 changes: 100 additions & 14 deletions src/coinbase/wallet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { HDKey } from "@scure/bip32";
import { mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
import { hexlify } from "ethers";
import * as crypto from "crypto";
import Decimal from "decimal.js";
import { ethers } from "ethers";
Expand Down Expand Up @@ -26,6 +29,9 @@ import {
StakeOptionsMode,
WalletCreateOptions,
WalletData,
WalletDataSnake,
isWalletData,
isWalletDataSnake,
CreateERC20Options,
CreateERC721Options,
CreateERC1155Options,
Expand Down Expand Up @@ -130,31 +136,82 @@ export class Wallet {
}

/**
* Imports a Wallet for the given Wallet data.
* Imports an external wallet into CDP using a BIP-39 mnemonic seed phrase.
*
* @param data - The Wallet data to import.
* @param data.walletId - The ID of the Wallet to import.
* @param data.seed - The seed to use for the Wallet.
* @param mnemonicSeedPhrase - The BIP-39 mnemonic seed phrase (12, 15, 18, 21, or 24 words).
* @returns The imported Wallet.
* @throws {ArgumentError} If the Wallet ID is not provided.
* @throws {ArgumentError} If the seed is not provided.
* @throws {ArgumentError} If the seed phrase is not provided or invalid.
* @throws {ArgumentError} If the seed phrase is not 12, 15, 18, 21, or 24 words.
* @throws {APIError} If the request fails.
*/
public static async import(data: WalletData): Promise<Wallet> {
if (!data.walletId) {
public static async importFromMnemonicSeedPhrase(mnemonicSeedPhrase: string): Promise<Wallet> {
if (!mnemonicSeedPhrase) {
throw new ArgumentError("BIP-39 mnemonic seed phrase must be provided");
}

if (!validateMnemonic(mnemonicSeedPhrase, wordlist)) {
throw new ArgumentError("Invalid BIP-39 mnemonic seed phrase");
}

// Convert mnemonic phrase to seed
const seedBuffer = mnemonicToSeedSync(mnemonicSeedPhrase);
const seed = hexlify(seedBuffer).slice(2); // remove 0x prefix

// Create wallet using the provided seed
const wallet = await Wallet.createWithSeed({
seed: seed,
networkId: Coinbase.networks.BaseSepolia,
});

// Ensure the wallet is created
await wallet.listAddresses();
return wallet;
}

/**
* Loads an existing CDP Wallet using a wallet data object.
*
* @param data - The Wallet data to load.
* @returns The loaded Wallet.
* @throws {ArgumentError} If the data format is invalid.
* @throws {ArgumentError} If the seed is not provided.
*/
public static async load(data: WalletData | WalletDataSnake): Promise<Wallet> {
if (!isWalletData(data) && !isWalletDataSnake(data)) {
throw new ArgumentError("Invalid wallet data format");
}

if ((isWalletData(data) && !data.walletId) || (isWalletDataSnake(data) && !data.wallet_id)) {
throw new ArgumentError("Wallet ID must be provided");
}

if (!data.seed) {
throw new ArgumentError("Seed must be provided");
}
const walletModel = await Coinbase.apiClients.wallet!.getWallet(data.walletId);

const walletModel = await Coinbase.apiClients.wallet!.getWallet(
isWalletData(data) ? data.walletId : data.wallet_id,
);
const wallet = Wallet.init(walletModel.data, data.seed);
await wallet.listAddresses();
return wallet;
}

/**
* Returns a newly created Wallet object.
* Loads an existing CDP wallet using a wallet data object.
*
* @deprecated Use load() instead
* @param data - The Wallet data to load.
* @returns The loaded Wallet.
* @throws {ArgumentError} If the data format is invalid.
* @throws {ArgumentError} If the seed is not provided.
*/
public static async import(data: WalletData | WalletDataSnake): Promise<Wallet> {
return Wallet.load(data);
}

/**
* Creates a new Wallet with a random seed.
*
* @constructs Wallet
* @param options - The options to create the Wallet.
Expand All @@ -170,6 +227,32 @@ export class Wallet {
networkId = Coinbase.networks.BaseSepolia,
timeoutSeconds = 20,
intervalSeconds = 0.2,
}: WalletCreateOptions = {}): Promise<Wallet> {
return Wallet.createWithSeed({
networkId,
timeoutSeconds,
intervalSeconds,
});
}

/**
* Creates a new Wallet with the given seed.
*
* @param options - The options to create the Wallet.
* @param options.seed - The seed to use for the Wallet. If undefined, a random seed will be generated.
* @param options.networkId - the ID of the blockchain network. Defaults to 'base-sepolia'.
* @param options.intervalSeconds - The interval at which to poll the backend, in seconds.
* @param options.timeoutSeconds - The maximum amount of time to wait for the ServerSigner to create a seed, in seconds.
* @throws {ArgumentError} If the model or client is not provided.
* @throws {Error} - If address derivation or caching fails.
* @throws {APIError} - If the request fails.
* @returns A promise that resolves with the new Wallet object.
*/
public static async createWithSeed({
seed = undefined,
networkId = Coinbase.networks.BaseSepolia,
timeoutSeconds = 20,
intervalSeconds = 0.2,
}: WalletCreateOptions = {}): Promise<Wallet> {
const result = await Coinbase.apiClients.wallet!.createWallet({
wallet: {
Expand All @@ -178,7 +261,7 @@ export class Wallet {
},
});

const wallet = Wallet.init(result.data, undefined);
const wallet = Wallet.init(result.data, seed);
if (Coinbase.useServerSigner) {
await wallet.waitForSigner(wallet.getId()!, intervalSeconds, timeoutSeconds);
}
Expand Down Expand Up @@ -221,7 +304,10 @@ export class Wallet {
if (!this.seed) {
throw new Error("Cannot export Wallet without loaded seed");
}
return { walletId: this.getId()!, seed: this.seed };
return {
walletId: this.getId()!,
seed: this.seed,
};
}

/**
Expand Down Expand Up @@ -912,8 +998,8 @@ export class Wallet {
* @param seed - The seed to use for the Wallet
*/
private validateSeed(seed: string | undefined): void {
if (seed && seed.length !== 64) {
throw new ArgumentError("Seed must be 32 bytes");
if (seed && seed.length !== 64 && seed.length !== 128) {
throw new ArgumentError("Seed must be 32 or 64 bytes");
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/tests/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("Coinbase SDK E2E Test", () => {
const walletId = Object.keys(seedFile)[0];
const seed = seedFile[walletId].seed;

const importedWallet = await Wallet.import({ seed, walletId });
const importedWallet = await Wallet.load({ seed, walletId });
expect(importedWallet).toBeDefined();
expect(importedWallet.getId()).toBe(walletId);
console.log(
Expand Down
Loading
Loading