Skip to main content

How to Create a QuickNode Function Using TypeScript

Updated on
Jul 30, 2024

13 min read

Overview

QuickNode Functions allow developers to quickly build and deploy serverless functions that can interact with blockchain data. In this guide, we'll walk you through creating a QuickNode Function using TypeScript to manage dynamic Solana wallet portfolios and fetch their balances.

What You Will Do

In this guide, you will:

  1. Set up a TypeScript development environment
  2. Create a QuickNode Function for managing Solana wallet portfolios using QuickNode's Key-Value Store
  3. Build and zip your function for deployment
  4. Test your function using curl commands

What You Will Need

Set Up Your Environment

First, let's create a new project directory and initialize it:

mkdir solana-portfolio-manager && cd solana-portfolio-manager

Next, initialize your project with the following command:

npm init -y

Now, let's install the necessary dependencies:

npm install @solana/web3.js dotenv

Create a tsconfig.json file in your project root:

npx tsc --init

Open tsconfig.json and update it to include:

{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

Create a src directory and an index.ts file inside it:

mkdir src && echo > src/index.ts

Build Your QuickNode Function

Let's start by creating our function. The function will manage a dynamic list of Solana wallet addresses. It will allow users to create a wallet list, update the list by adding/removing addresses, retrieve the list, and fetch the balances of the wallets in the list. To enable multiple instructions in a single function, we'll use a switch statement to handle the different instructions.

Import Dependencies and Define Constants

Open src/index.ts in your code editor and add the following code:

import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import * as dotenv from "dotenv";

dotenv.config();

interface QNLib {
qnUpsertList: (key: string, options: { add_items?: string[], remove_items?: string[] }) => Promise<any>;
qnGetList: (key: string) => Promise<string[]>;
}
declare const qnLib: QNLib;
const ENDPOINT = process.env.SOLANA_RPC_ENDPOINT;

Here, we are importing a few dependencies from the @solana/web3.js library - we will use these to validate user inputs and fetch data from the Solana blockchain. We are also defining a QNLib interface which will allow us to ensure Type safety when using the qnLib object available in the global scope of QuickNode Functions.

Finally, we define our ENDPOINT variable using the process.env.SOLANA_RPC_ENDPOINT variable from the .env file. Let's create that file now.

Create a .env File

Create a .env file in your project root and add the following code:

SOLANA_RPC_ENDPOINT=https://example.solana-mainnet.quiknode.pro/123/ # 👈 Replace with your endpoint

Make sure you replace the SOLANA_RPC_ENDPOINT variable with your own endpoint URL. You can find this in the QuickNode Dashboard under the Endpoints tab.

Define Types and Interfaces

Now that we have our dependencies and constants defined let's define the types and interfaces we will use in our function to help with type safety and better code organization. Below your constants, add the following code:

type Instruction = 'createPortfolio' | 'updatePortfolio' | 'getPortfolio' | 'getPortfolioBalances';

interface UserData {
instruction: Instruction;
portfolioName: string;
addAddresses?: string[];
removeAddresses?: string[];
}

interface FunctionParams {
user_data: UserData;
}

interface FunctionResult {
message: string;
portfolioName: string;
addresses?: string[];
balances?: { address: string; balance: number }[];
error?: string;
}

Let's walk through what each of these types and interfaces are responsible for:

  1. Instruction: This is a Type that defines the different instructions that can be executed by the function. In this case, we define four instructions: createPortfolio, updatePortfolio, getPortfolio, and getPortfolioBalances.
  2. UserData: This interface defines the data that will be passed to the function. It contains three properties: instruction (an Instruction), portfolioName (a unique identifier for the portfolio), and addAddresses and removeAddresses (Solana wallet addresses).
  3. FunctionParams: This interface defines the parameters that will be passed to the function. It contains one property: user_data of type UserData.
  4. FunctionResult: This interface defines the result that the function will return. All responses must include a message and portfolioName property. Depending on the instruction, the response may also include addresses, balances, and error properties.

Add Helper Functions

Let's create two helper functions to validate user inputs and handle errors. Add the following code to the bottom of your index.ts file:

function isValidSolanaAddress(address: string): boolean {
try {
new PublicKey(address);
return true;
} catch (error) {
return false;
}
}

function validateInput(params: FunctionParams): void {
if (!ENDPOINT) {
throw new Error('SOLANA_RPC_ENDPOINT is not set');
}

const validInstructions: Instruction[] = ['createPortfolio', 'updatePortfolio', 'getPortfolio', 'getPortfolioBalances'];
if (!validInstructions.includes(params.user_data.instruction)) {
throw new Error(`Invalid instruction: ${params.user_data.instruction}. Must be one of: ${validInstructions.join(', ')}`);
}

if (!params.user_data.portfolioName) {
throw new Error('Portfolio name is required');
}

if (params.user_data.instruction === 'updatePortfolio') {
if (!params.user_data.addAddresses && !params.user_data.removeAddresses) {
throw new Error('At least one of addAddresses or removeAddresses is required for updatePortfolio instruction');
}

if (params.user_data.addAddresses) {
const invalidAddAddresses = params.user_data.addAddresses.filter(addr => !isValidSolanaAddress(addr));
if (invalidAddAddresses.length > 0) {
throw new Error(`Invalid Solana addresses: ${invalidAddAddresses.join(', ')}`);
}
}
}
}

Here, we are defining two helper functions:

  • isValidSolanaAddress: used to validate user input for Solana addresses. It checks if the input is a valid Solana address by trying to create a new PublicKey object from the input string. If the input is not a valid Solana address, it returns false. Otherwise, it returns true.
  • validateInput: used to validate the endpoint and user input for the portfolio name and the instructions. It checks if a valid instruction is provided and if the portfolio name is not empty. If the updatePortfolio instruction is provided, it also checks if at least one of the addAddresses or removeAddresses is provided. If adding a new address to the portfolio, it also checks if the addresses are valid Solana addresses. If any of these conditions are not met, it throws an error.

Define Instruction Handlers

Next, we will define an instruction handler for each instruction. Add the following code to the bottom of your index.ts file:

async function createPortfolio(portfolioName: string): Promise<FunctionResult> {
await qnLib.qnUpsertList(portfolioName, { add_items: [] });
return {
message: `Portfolio ${portfolioName} created successfully.`,
portfolioName
};
}

async function updatePortfolio(portfolioName: string, addAddresses: string[] = [], removeAddresses: string[] = []): Promise<FunctionResult> {
await qnLib.qnUpsertList(portfolioName, { add_items: addAddresses, remove_items: removeAddresses });
const updatedPortfolio = await qnLib.qnGetList(portfolioName);
return {
message: `Updated portfolio ${portfolioName}. Added ${addAddresses.length} addresses, removed ${removeAddresses.length} addresses.`,
portfolioName,
addresses: updatedPortfolio
};
}

async function getPortfolio(portfolioName: string): Promise<FunctionResult> {
const addresses = await qnLib.qnGetList(portfolioName);
return {
message: `Retrieved portfolio ${portfolioName}.`,
portfolioName,
addresses
};
}

async function getPortfolioBalances(portfolioName: string): Promise<FunctionResult> {
const addresses = await qnLib.qnGetList(portfolioName);

// @ts-ignore - Already validated in validateInput
const connection = new Connection(ENDPOINT);

const balances = await Promise.all(
addresses.map(async (address) => {
const publicKey = new PublicKey(address);
const balance = await connection.getBalance(publicKey);
return {
address,
balance: balance / LAMPORTS_PER_SOL
};
})
);

return {
message: `Retrieved balances for portfolio ${portfolioName}.`,
portfolioName,
balances
};
}

Here, we are defining four instruction handlers:

  • createPortfolio: creates a new portfolio with an empty list of addresses using the qnLib.upsertList method.
  • updatePortfolio: updates an existing portfolio by adding or removing addresses using the qnLib.upsertList method.
  • getPortfolio: retrieves the list of addresses for a given portfolio using the qnLib.getList method.
  • getPortfolioBalances: retrieves the balances for a given portfolio using the @solana/web3.js library.

Define Main Function

Now that we have defined our helper functions and instruction handlers, let's define the main function that will serve as the entry point. Add the following code to the bottom of your index.ts file:

export async function main(params: FunctionParams): Promise<FunctionResult> {
try {
validateInput(params);

const { instruction, portfolioName, addAddresses, removeAddresses } = params.user_data;

switch (instruction) {
case 'createPortfolio':
return await createPortfolio(portfolioName);
case 'updatePortfolio':
return await updatePortfolio(portfolioName, addAddresses, removeAddresses);
case 'getPortfolio':
return await getPortfolio(portfolioName);
case 'getPortfolioBalances':
return await getPortfolioBalances(portfolioName);
default:
throw new Error('Invalid instruction');
}
} catch (error) {
console.error('Error:', error);
return {
message: 'An error occurred',
portfolioName: params.user_data.portfolioName,
error: error instanceof Error ? error.message : String(error)
};
}
}

Here, we simply validate our input parameters and then call the appropriate instruction handler based on the instruction property in the user_data object. If any errors occur during validation or execution, we catch them and return a FunctionResult object with an error property.

Create Build and Package Scripts

Now that we have our function code let's set up the build process. Add the following scripts to your package.json:

  "scripts": {
"build": "tsc",
"zip": "npm run build && zip -r function.zip index.js package.json node_modules .env"
},

This will create two scripts in your package.json file: build and zip. The build script will compile your TypeScript code and create an index.js file in your project root. The zip script will run the build script and then create a function.zip file in your project root, including the necessary files for a QuickNode Function.

Build and Package Your Function

To build and package your Function, run:

npm run build

And then run:

npm run zip

This will compile your TypeScript code and create a function.zip file in your project root.

Deploy Your Function to QuickNode

Now that you have zipped your Function, you can deploy it to QuickNode. Head over to the Function's Page on your QuickNode Dashboard and click the + Create Function button. Then, select Upload a zip file and upload the function.zip file you just created. Finally, set the Environment to Node.js 20 and click Create Function. If you haven't already created a Namespace, select "+ Create New Namespace" from the dropdown. Otherwise, select the Namespace you want to use for your Function.

You can define your Function's name and description and use the default setup:

Function Settings

Click Create Function to advance to the next step.

Here, you will need to select .zip from the dropdown menu:

Select Zip File

Then, you can select the function.zip file you just created or drag and drop it into the box. When you are done, click Save & Close. You can ignore the "Testing Parameters" section, which includes chains we will not use in this guide.

Great job! Your Function is now deployed to QuickNode. You should see something like this:

Your Function is Deployed

Let's test it out (you will need your Function's endpoint URL and API key - see image above).

Test Your QuickNode Function

Now that your Function is deployed, you can test it using curl commands or any tool like Postman. Replace YOUR_FUNCTION_URL and YOUR_API_KEY with your actual Function URL and API key from QuickNode.

Create a Portfolio

Go ahead and create a portfolio with the name "TEST". Note that just like our Function, our parameters are passed through the user_data object. We define our instruction as createPortfolio and our portfolioName as TEST:

curl -X POST "YOUR_FUNCTION_URL" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"user_data": {
"instruction": "createPortfolio",
"portfolioName": "TEST"
}
}'

You should see a response like this:

{
"result": {
"message": "Portfolio TEST created successfully.",
"portfolioName": "TEST"
},
"size": 73,
"status": "success",
"success": true
}

Add Addresses to a Portfolio

Now let's add a couple of addresses to our portfolio. Note that we are using the updatePortfolio instruction and passing in an array of addresses to add to our portfolio. Feel free to replace the address below with one or more of your own:

curl -X POST "YOUR_FUNCTION_URL" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"user_data": {
"instruction": "updatePortfolio",
"portfolioName": "TEST",
"addAddresses": ["7v91N7iZ9mNicL8WfG6cgSCKyRXydQjLh6UYBWwm6y1Q"]
}
}'

Note that we are using the same portfolioName as before. You should see a response like this:

{
"result": {
"addresses": [
"7v91N7iZ9mNicL8WfG6cgSCKyRXydQjLh6UYBWwm6y1Q"
],
"message": "Updated portfolio TEST. Added 1 addresses, removed 0 addresses.",
"portfolioName": "TEST"
},
"size": 161,
"status": "success",
"success": true
}

Get Portfolio Balances

Finally, let's get the balances for our portfolio. Note, we are using the getPortfolioBalances instruction and passing in the same portfolioName as before ("TEST"):

curl -X POST "YOUR_FUNCTION_URL" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"user_data": {
"instruction": "getPortfolioBalances",
"portfolioName": "TEST"
}
}'

You should see a response like this:

{
"result": {
"balances": [
{
"address": "7v91N7iZ9mNicL8WfG6cgSCKyRXydQjLh6UYBWwm6y1Q",
"balance": 0
}
],
"message": "Retrieved balances for portfolio TEST.",
"portfolioName": "TEST"
},
"size": 159,
"status": "success",
"success": true
}

Nice job!

Conclusion

Congratulations! You've successfully created a QuickNode Function using TypeScript to manage Solana wallet portfolios. This function demonstrates how to interact with QuickNode's Key-Value Store and the Solana blockchain to create, update, and retrieve portfolio information.

To learn more about QuickNode Functions and explore other capabilities, check out our documentation and join our Discord community for support and discussions.

Want to keep building with Functions? Check out our Functions Library for more examples and inspiration.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide