@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
755 lines (701 loc) • 24.1 kB
text/typescript
/**
* @module Client
*/
import { constants, publicEncrypt } from "crypto";
import bs58 from "bs58";
import {
CreateAccountOptions,
ExportAccountOptions,
GetAccountOptions,
GetOrCreateAccountOptions,
ImportAccountOptions,
ListAccountsOptions,
ListAccountsResult,
ListTokenBalancesOptions,
ListTokenBalancesResult,
RequestFaucetOptions,
SendTransactionOptions,
SignatureResult,
SignMessageOptions,
SignTransactionOptions,
SolanaClientInterface,
UpdateSolanaAccountOptions,
} from "./solana.types.js";
import { toSolanaAccount } from "../../accounts/solana/toSolanaAccount.js";
import { SolanaAccount } from "../../accounts/solana/types.js";
import { requestFaucet } from "../../actions/solana/requestFaucet.js";
import {
sendTransaction,
type SendTransactionResult,
} from "../../actions/solana/sendTransaction.js";
import { signMessage } from "../../actions/solana/signMessage.js";
import {
signTransaction,
type SignTransactionResult,
} from "../../actions/solana/signTransaction.js";
import { Analytics } from "../../analytics.js";
import { ImportAccountPublicRSAKey } from "../../constants.js";
import { UserInputValidationError } from "../../errors.js";
import { APIError } from "../../openapi-client/errors.js";
import { CdpOpenApiClient } from "../../openapi-client/index.js";
import {
decryptWithPrivateKey,
formatSolanaPrivateKey,
generateExportEncryptionKeyPair,
} from "../../utils/export.js";
/**
* The namespace containing all Solana methods.
*/
export class SolanaClient implements SolanaClientInterface {
/**
* Creates a new Solana account.
*
* @param {CreateAccountOptions} options - Parameters for creating the Solana account.
* @param {string} [options.name] - The name of the account.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the newly created account.
*
* @example **Without arguments**
* ```ts
* const account = await cdp.solana.createAccount();
* ```
*
* @example **With a name**
* ```ts
* const account = await cdp.solana.createAccount({ name: "MyAccount" });
* ```
*
* @example **With an idempotency key**
* ```ts
* const idempotencyKey = uuidv4();
*
* // First call
* await cdp.solana.createAccount({ idempotencyKey });
*
* // Second call with the same idempotency key will return the same account
* await cdp.solana.createAccount({ idempotencyKey });
* ```
*/
async createAccount(options: CreateAccountOptions = {}): Promise<SolanaAccount> {
Analytics.trackAction({
action: "create_account",
accountType: "solana",
});
try {
return this._createAccountInternal(options);
} catch (error) {
Analytics.trackError(error, "createAccount");
throw error;
}
}
/**
* Exports a CDP Solana account's private key.
* It is important to store the private key in a secure place after it's exported.
*
* @param {ExportAccountOptions} options - Parameters for exporting the Solana account.
* @param {string} [options.address] - The address of the account.
* @param {string} [options.name] - The name of the account.
*
* @returns A promise that resolves to the exported account's full 64-byte private key as a base58 encoded string.
*
* @example **With an address**
* ```ts
* const privateKey = await cdp.solana.exportAccount({
* address: "1234567890123456789012345678901234567890",
* });
* ```
*
* @example **With a name**
* ```ts
* const privateKey = await cdp.solana.exportAccount({
* name: "MyAccount",
* });
* ```
*/
async exportAccount(options: ExportAccountOptions): Promise<string> {
Analytics.trackAction({
action: "export_account",
accountType: "solana",
});
try {
const { publicKey, privateKey } = await generateExportEncryptionKeyPair();
const { encryptedPrivateKey } = await (async () => {
if (options.address) {
return CdpOpenApiClient.exportSolanaAccount(
options.address,
{
exportEncryptionKey: publicKey,
},
options.idempotencyKey,
);
}
if (options.name) {
return CdpOpenApiClient.exportSolanaAccountByName(
options.name,
{
exportEncryptionKey: publicKey,
},
options.idempotencyKey,
);
}
throw new UserInputValidationError("Either address or name must be provided");
})();
const decryptedPrivateKey = decryptWithPrivateKey(privateKey, encryptedPrivateKey);
return await formatSolanaPrivateKey(decryptedPrivateKey);
} catch (error) {
if (!(error instanceof UserInputValidationError)) {
Analytics.trackError(error, "exportAccount");
}
throw error;
}
}
/**
* Imports a Solana account using a private key.
* The private key will be encrypted before being stored securely.
*
* @param {ImportAccountOptions} options - Parameters for importing the Solana account.
* @param {string} options.privateKey - The private key to import (32 or 64 bytes). Can be a base58 encoded string or raw bytes.
* @param {string} [options.name] - The name of the account.
* @param {string} [options.encryptionPublicKey] - The RSA public key for encrypting the private key.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the imported account.
*
* @example **Import with private key only**
* ```ts
* const account = await cdp.solana.importAccount({
* privateKey: "3Kzj...",
* });
* ```
*
* @example **Import with name**
* ```ts
* const account = await cdp.solana.importAccount({
* privateKey: "3Kzj...",
* name: "ImportedAccount",
* });
* ```
*
* @example **Import with idempotency key**
* ```ts
* const idempotencyKey = uuidv4();
*
* const account = await cdp.solana.importAccount({
* privateKey: "3Kzj...",
* name: "ImportedAccount",
* idempotencyKey,
* });
* ```
*/
async importAccount(options: ImportAccountOptions): Promise<SolanaAccount> {
Analytics.trackAction({
action: "import_account",
accountType: "solana",
});
try {
let privateKeyBytes: Uint8Array = new Uint8Array();
if (typeof options.privateKey === "string") {
privateKeyBytes = bs58.decode(options.privateKey);
} else {
privateKeyBytes = options.privateKey;
}
if (privateKeyBytes.length !== 32 && privateKeyBytes.length !== 64) {
throw new UserInputValidationError("Invalid private key length");
}
if (privateKeyBytes.length === 64) {
privateKeyBytes = privateKeyBytes.subarray(0, 32);
}
const encryptionPublicKey = options.encryptionPublicKey || ImportAccountPublicRSAKey;
const encryptedPrivateKey = publicEncrypt(
{
key: encryptionPublicKey,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
privateKeyBytes,
);
const openApiAccount = await CdpOpenApiClient.importSolanaAccount(
{
encryptedPrivateKey: encryptedPrivateKey.toString("base64"),
name: options.name,
},
options.idempotencyKey,
);
const account = toSolanaAccount(CdpOpenApiClient, {
account: openApiAccount,
});
return account;
} catch (error) {
if (!(error instanceof UserInputValidationError)) {
Analytics.trackError(error, "importAccount");
}
throw error;
}
}
/**
* Gets a Solana account by its address.
*
* @param {GetAccountOptions} options - Parameters for getting the Solana account.
* Either `address` or `name` must be provided.
* If both are provided, lookup will be done by `address` and `name` will be ignored.
* @param {string} [options.address] - The address of the account.
* @param {string} [options.name] - The name of the account.
*
* @returns A promise that resolves to the account.
*
* @example **Get an account by address**
* ```ts
* const account = await cdp.solana.getAccount({
* address: "1234567890123456789012345678901234567890",
* });
* ```
*
* @example **Get an account by name**
* ```ts
* const account = await cdp.solana.getAccount({
* name: "MyAccount",
* });
* ```
*/
async getAccount(options: GetAccountOptions): Promise<SolanaAccount> {
Analytics.trackAction({
action: "get_account",
accountType: "solana",
});
try {
return this._getAccountInternal(options);
} catch (error) {
Analytics.trackError(error, "getAccount");
throw error;
}
}
/**
* Gets a Solana account by its address.
*
* @param {GetOrCreateAccountOptions} options - Parameters for getting or creating the Solana account.
* @param {string} options.name - The name of the account.
*
* @returns A promise that resolves to the account.
*
* @example
* ```ts
* const account = await cdp.solana.getOrCreateAccount({
* name: "MyAccount",
* });
* ```
*/
async getOrCreateAccount(options: GetOrCreateAccountOptions): Promise<SolanaAccount> {
Analytics.trackAction({
action: "get_or_create_account",
accountType: "solana",
});
try {
try {
const account = await this._getAccountInternal(options);
return account;
} catch (error) {
// If it failed because the account doesn't exist, create it
const doesAccountNotExist = error instanceof APIError && error.statusCode === 404;
if (doesAccountNotExist) {
try {
const account = await this._createAccountInternal(options);
return account;
} catch (error) {
// If it failed because the account already exists, get the existing account
const doesAccountAlreadyExist = error instanceof APIError && error.statusCode === 409;
if (doesAccountAlreadyExist) {
const account = await this._getAccountInternal(options);
return account;
}
throw error;
}
}
throw error;
}
} catch (error) {
Analytics.trackError(error, "getOrCreateAccount");
throw error;
}
}
/**
* Lists all Solana accounts.
*
* @param {ListAccountsOptions} options - Parameters for listing the Solana accounts.
* @param {number} [options.pageSize] - The number of accounts to return.
* @param {string} [options.pageToken] - The page token to begin listing from.
* This is obtained by previous calls to this method.
*
* @returns A promise that resolves to an array of Solana account instances.
*
* @example **Without arguments**
* ```ts
* const accounts = await cdp.solana.listAccounts();
* ```
*
* @example **With pagination**
* ```ts
* let page = await cdp.solana.listAccounts();
*
* while (page.nextPageToken) {
* page = await cdp.solana.listAccounts({ pageToken: page.nextPageToken });
* }
*
* page.accounts.forEach(account => console.log(account));
* ```
* }
* ```
*/
async listAccounts(options: ListAccountsOptions = {}): Promise<ListAccountsResult> {
Analytics.trackAction({
action: "list_accounts",
accountType: "solana",
});
try {
const solAccounts = await CdpOpenApiClient.listSolanaAccounts({
pageSize: options.pageSize,
pageToken: options.pageToken,
});
return {
accounts: solAccounts.accounts.map(account => {
const solanaAccount = toSolanaAccount(CdpOpenApiClient, {
account,
});
return solanaAccount;
}),
nextPageToken: solAccounts.nextPageToken,
};
} catch (error) {
Analytics.trackError(error, "listAccounts");
throw error;
}
}
/**
* Requests funds from a Solana faucet.
*
* @param {RequestFaucetOptions} options - Parameters for requesting funds from the Solana faucet.
* @param {string} options.address - The address to request funds for.
* @param {string} options.token - The token to request funds for.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the transaction signature.
*
* @example
* ```ts
* const signature = await cdp.solana.requestFaucet({
* address: "1234567890123456789012345678901234567890",
* token: "sol",
* });
* ```
*/
async requestFaucet(options: RequestFaucetOptions): Promise<SignatureResult> {
Analytics.trackAction({
action: "request_faucet",
accountType: "solana",
});
try {
return requestFaucet(CdpOpenApiClient, options);
} catch (error) {
Analytics.trackError(error, "requestFaucet");
throw error;
}
}
/**
* Signs a message.
*
* @param {SignMessageOptions} options - Parameters for signing the message.
* @param {string} options.address - The address to sign the message for.
* @param {string} options.message - The message to sign.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the signature.
*
* @example
* ```ts
* // Create a Solana account
* const account = await cdp.solana.createAccount();
*
* // When you want to sign a message, you can do so by address
* const signature = await cdp.solana.signMessage({
* address: account.address,
* message: "Hello, world!",
* });
* ```
*/
async signMessage(options: SignMessageOptions): Promise<SignatureResult> {
Analytics.trackAction({
action: "sign_message",
accountType: "solana",
});
try {
return signMessage(CdpOpenApiClient, options);
} catch (error) {
Analytics.trackError(error, "signMessage");
throw error;
}
}
/**
* Signs a transaction.
*
* @param {SignTransactionOptions} options - Parameters for signing the transaction.
* @param {string} options.address - The address to sign the transaction for.
* @param {string} options.transaction - The transaction to sign.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the signature.
*
* @example
* ```ts
* // Create a Solana account
* const account = await cdp.solana.createAccount();
*
* // Build your transaction using @solana/kit
* import {
* address as solanaAddress,
* appendTransactionMessageInstructions,
* compileTransaction,
* createNoopSigner,
* createSolanaRpc,
* createTransactionMessage,
* getBase64EncodedWireTransaction,
* pipe,
* setTransactionMessageFeePayer,
* setTransactionMessageLifetimeUsingBlockhash,
* } from "@solana/kit";
* import { getTransferSolInstruction } from "@solana-program/system";
*
* const rpc = createSolanaRpc("https://api.devnet.solana.com");
* const { value: { blockhash, lastValidBlockHeight } } = await rpc.getLatestBlockhash().send();
*
* const txMsg = pipe(
* createTransactionMessage({ version: 0 }),
* (tx) => setTransactionMessageFeePayer(solanaAddress(account.address), tx),
* (tx) => setTransactionMessageLifetimeUsingBlockhash(
* { blockhash, lastValidBlockHeight },
* tx,
* ),
* (tx) => appendTransactionMessageInstructions([
* getTransferSolInstruction({
* source: createNoopSigner(solanaAddress(account.address)),
* destination: solanaAddress("3KzDtddx4i53FBkvCzuDmRbaMozTZoJBb1TToWhz3JfE"),
* amount: 10000n,
* }),
* ], tx),
* );
*
* // Base64 encode the compiled transaction
* const transaction = getBase64EncodedWireTransaction(compileTransaction(txMsg));
*
* // Sign the transaction via the CDP API
* const signature = await cdp.solana.signTransaction({
* address: account.address,
* transaction,
* });
* ```
*/
async signTransaction(options: SignTransactionOptions): Promise<SignTransactionResult> {
Analytics.trackAction({
action: "sign_transaction",
accountType: "solana",
});
try {
return signTransaction(CdpOpenApiClient, options);
} catch (error) {
Analytics.trackError(error, "signTransaction");
throw error;
}
}
/**
* Updates a CDP Solana account.
*
* @param {UpdateSolanaAccountOptions} [options] - Optional parameters for creating the account.
* @param {string} options.address - The address of the account to update
* @param {UpdateSolanaAccountBody} options.update - An object containing account fields to update.
* @param {string} [options.update.name] - The new name for the account.
* @param {string} [options.update.accountPolicy] - The ID of a Policy to apply to the account.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the updated account.
*
* @example **With a name**
* ```ts
* const account = await cdp.sol.updateAccount({ address: "...", update: { name: "New Name" } });
* ```
*
* @example **With an account policy**
* ```ts
* const account = await cdp.sol.updateAccount({ address: "...", update: { accountPolicy: "73bcaeeb-d7af-4615-b064-42b5fe83a31e" } });
* ```
*
* @example **With an idempotency key**
* ```ts
* const idempotencyKey = uuidv4();
*
* // First call
* await cdp.sol.updateAccount({
* address: "0x...",
* update: { accountPolicy: "73bcaeeb-d7af-4615-b064-42b5fe83a31e" },
* idempotencyKey,
* });
*
* // Second call with the same idempotency key will not update
* await cdp.sol.updateAccount({
* address: '0x...',
* update: { name: "" },
* idempotencyKey,
* });
* ```
*/
async updateAccount(options: UpdateSolanaAccountOptions): Promise<SolanaAccount> {
Analytics.trackAction({
action: "update_account",
accountType: "solana",
});
try {
const openApiAccount = await CdpOpenApiClient.updateSolanaAccount(
options.address,
options.update,
options.idempotencyKey,
);
const account = toSolanaAccount(CdpOpenApiClient, {
account: openApiAccount,
});
return account;
} catch (error) {
Analytics.trackError(error, "updateAccount");
throw error;
}
}
/**
* Sends a Solana transaction using the Coinbase API.
*
* @param {SendTransactionOptions} options - Parameters for sending the Solana transaction.
* @param {string} options.network - The network to send the transaction to.
* @param {string} options.transaction - The base64 encoded transaction to send.
* @param {boolean} [options.useCdpSponsor] - Whether CDP should sponsor the transaction fees.
* @param {string} [options.idempotencyKey] - An idempotency key.
*
* @returns A promise that resolves to the transaction result.
*
* @example
* ```ts
* const signature = await cdp.solana.sendTransaction({
* network: "solana-devnet",
* transaction: "...",
* useCdpSponsor: true,
* });
* ```
*/
async sendTransaction(options: SendTransactionOptions): Promise<SendTransactionResult> {
Analytics.trackAction({
action: "send_transaction",
accountType: "solana",
properties: {
network: options.network,
},
});
try {
return sendTransaction(CdpOpenApiClient, options);
} catch (error) {
Analytics.trackError(error, "sendTransaction");
throw error;
}
}
/**
* Lists the token balances for a Solana account.
*
* @param {ListTokenBalancesOptions} options - Parameters for listing the Solana token balances.
* @param {string} options.address - The address of the account to list token balances for.
* @param {string} [options.network] - The network to list token balances for. Defaults to "solana".
* @param {number} [options.pageSize] - The number of token balances to return.
* @param {string} [options.pageToken] - The page token to begin listing from.
* This is obtained by previous calls to this method.
*
* @returns A promise that resolves to an array of Solana token balance instances.
*
* @example
* ```ts
* const balances = await cdp.solana.listTokenBalances({ address: "...", network: "solana-devnet" });
* ```
*/
async listTokenBalances(options: ListTokenBalancesOptions): Promise<ListTokenBalancesResult> {
Analytics.trackAction({
action: "list_token_balances",
accountType: "solana",
properties: {
network: options.network,
},
});
try {
const tokenBalances = await CdpOpenApiClient.listSolanaTokenBalances(
options.network || "solana",
options.address,
{
pageSize: options.pageSize,
pageToken: options.pageToken,
},
);
return {
balances: tokenBalances.balances.map(balance => {
return {
amount: {
amount: BigInt(balance.amount.amount),
decimals: balance.amount.decimals,
},
token: {
mintAddress: balance.token.mintAddress,
name: balance.token.name,
symbol: balance.token.symbol,
},
};
}),
nextPageToken: tokenBalances.nextPageToken,
};
} catch (error) {
Analytics.trackError(error, "listTokenBalances");
throw error;
}
}
/**
* Internal method to create a Solana account without tracking analytics.
* Used internally by composite operations to avoid double-counting.
*
* @param {CreateAccountOptions} options - Parameters for creating the account.
* @returns {Promise<SolanaAccount>} A promise that resolves to the newly created account.
*/
private async _createAccountInternal(options: CreateAccountOptions = {}): Promise<SolanaAccount> {
const openApiAccount = await CdpOpenApiClient.createSolanaAccount(
{
name: options.name,
accountPolicy: options.accountPolicy,
},
options.idempotencyKey,
);
const account = toSolanaAccount(CdpOpenApiClient, {
account: openApiAccount,
});
return account;
}
/**
* Internal method to get a Solana account without tracking analytics.
* Used internally by composite operations to avoid double-counting.
*
* @param {GetAccountOptions} options - Parameters for getting the account.
* @returns {Promise<SolanaAccount>} A promise that resolves to the account.
*/
private async _getAccountInternal(options: GetAccountOptions): Promise<SolanaAccount> {
const openApiAccount = await (() => {
if (options.address) {
return CdpOpenApiClient.getSolanaAccount(options.address);
}
if (options.name) {
return CdpOpenApiClient.getSolanaAccountByName(options.name);
}
throw new UserInputValidationError("Either address or name must be provided");
})();
const account = toSolanaAccount(CdpOpenApiClient, {
account: openApiAccount,
});
return account;
}
}