Blinks on AWS with SST

denrei

Denrei Keith De Jesus

Posted on November 8, 2024

Blinks on AWS with SST

Most projects I know that revolves around Blinks or Blockchain Links often use Next.js's API Routes to develop and deploy Blinks. But did you know that apart from Next.js, you can actually build Blinks on your favorite Node backend? In this article, we'll use Serverless Stack (SST) to develop and deploy our Blinks on AWS!


What are Blinks?


Dialect Blinks

Blockchain Links, or Blinks turn any Solana Action into a shareable, metadata-rich link. Blinks allow Action-aware clients such as browser extension wallets, bots, etc. to display additional capabilities for the user (Blockchain Links and Solana Actions).

What are Solana Actions?


Solana Actions Execution and Lifecycle

Solana Actions are specification-compliant APIs that return transactions on the Solana blockchain to be previewed, signed, and sent accross various contexts, including QR codes, buttons + widgets in mobile and desktop application and websites across the internet (Blockchain Links and Solana Actions).

What is SST?

Serverless Stack (SST) is a framework that makes it easy to build modern full-stack applications on your own infrastructure (SST).


What we'll build

A simple Donate Blink that allows users to donate to a specific wallet address on devnet.

If you want to know more about how Blinks work, you can check out the Solana Actions and Blockchain Links documentation.

Prerequisites

  1. Node.js installed on your machine.
  2. AWS Account

Project Setup

If you want to check the reference code, you can check it out here.

Let's create the project and install the dependencies.

mkdir sst-blinks && cd sst-blinks
npm init # answer the questions as you see fit

# dependencies
npm i typescript @solana/web3.js @solana/actions aws-sdk
# dev dependencies
npm i typescript @aws-sdk/types @types/aws-lambda --save-dev
Enter fullscreen mode Exit fullscreen mode

Initialize typescript

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

Setup tsconfig.json by copying this configuration

{
  "compilerOptions": {
    "types": ["node"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "isolatedModules": true,
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules"]
}

Enter fullscreen mode Exit fullscreen mode

Initialize SST.

As of the time of writing, the SST version is 3.3.3

npx sst init
# answer the default questions
# Template: js
# Using: aws
Enter fullscreen mode Exit fullscreen mode

Don't forget to add a .gitignore file, we don't want to commit the node_modules and .sst folders

# contents of your .gitignore, feel free to add more

node_modules
.sst
Enter fullscreen mode Exit fullscreen mode

After setting up everything, you have this project structure:

.
├── package-lock.json
├── package.json
├── README.md
├── sst-env.d.ts
├── sst.config.ts
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Create a src directory and add the following files

...
├── src
│   ├── actions.ts
│   ├── donate.ts
│   └── util.ts
...
Enter fullscreen mode Exit fullscreen mode

Now this is the final project structure

.
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── actions.ts
│   ├── donate.ts
│   └── util.ts
├── sst-env.d.ts
├── sst.config.ts
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Building the Donate Blink

Now that we've setup the project, let's build the API!

If want to check out the full code, you can find it here!

Configure AWS CLI

SST uses the AWS CLI to deploy your project. Make sure you have the AWS CLI installed and configured to your AWS account as SST will deploy the resources there you can read more here.

Creating the GET & OPTIONS donate endpoint

Let's create the endpoint that will return the metadata of the blink. This will be a GET method.

import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';

import { ActionGetResponse, ACTIONS_CORS_HEADERS, createPostResponse, LinkedAction } from '@solana/actions';

import { APIGatewayProxyEvent, Handler } from 'aws-lambda';

const DONATION_DESTINATION_WALLET = 'EQb8LApPTtZFk3cY7WcaAmEvuL6s3Q1q8ozxcPcBJ5dc'; // Replace with your own wallet address
const DONATION_AMOUNT_SOL_OPTIONS = [1, 5, 10];
const DEFAULT_DONATION_AMOUNT_SOL = 1;

export const get: Handler = async (event: APIGatewayProxyEvent, context) => {
  const amountParameterName = 'amount';
  const actionMetadata: ActionGetResponse = {
    icon: 'https://pbs.twimg.com/profile_images/1472933274209107976/6u-LQfjG_400x400.jpg', // Replace with your own icon
    label: `${DEFAULT_DONATION_AMOUNT_SOL} SOL`,
    title: 'Donate',
    description: 'Donate to support the project',
    links: {
      actions: [
        ...DONATION_AMOUNT_SOL_OPTIONS.map(
          (amount): LinkedAction => ({
            type: 'post',
            label: `${amount} SOL`,
            href: `/api/donate/${amount}`,
          }),
        ),
        {
          type: 'post',
          href: `/api/donate/{${amountParameterName}}`,
          label: 'Donate',
          parameters: [
            {
              name: amountParameterName,
              label: 'Enter a custom SOL amount',
            },
          ],
        },
      ],
    },
  };
  return {
    statusCode: 200,
    headers: ACTIONS_CORS_HEADERS,
    body: JSON.stringify(actionMetadata),
  };
};

export const options = get;
Enter fullscreen mode Exit fullscreen mode

Code walkthrough

Under actionMetadata, it includes data on how a blink will be displayed. You can check its properties here.

Within links.actions, it specifies an array of actions that can be performed. In this case, we have a list of donation amounts (1, 5, 10) and a custom donation amount.

Every action has a corresponding href that points to the API endpoint that will handle the action.

The get function returns the metadata of the action as well as the CORS headers.

The options function is a simple copy of the get function. It is used to handle the preflight request for CORS.

Configure the endpoint in sst.config.ts

export default $config({
  app(input) {
    return {
      name: 'sst-blinks',
      removal: input?.stage === 'production' ? 'retain' : 'remove',
      home: 'aws',
    };
  },
  async run() {
    const api = new sst.aws.ApiGatewayV2('Actions');

    api.route('GET /api/donate', {
      handler: 'src/donate.get',
    });
    api.route('OPTIONS /api/donate', {
      handler: 'src/donate.options',
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

Upon initializing sst in your project, you will have a minimal config of sst.

What we've added is in the run function where we create an API Gateway with the name Actions and add two routes:

Method url
GET /api/donate
OPTIONS /api/donate

This means that the API will have two endpoints that will handle the GET and OPTIONS requests.

Run the command

# Development mode
npx sst dev
Enter fullscreen mode Exit fullscreen mode

SST may take a while to deploy the resources on your AWS Account, but once it is successful, it will output the URL of the API.

https://<api-id>.execute-api.<region>.amazonaws.com/<api-endpoint>

Test the blink

You can check the blink by going to dial.to and pasting the link of your API with this format:

https://dial.to/developer?url=<your-absolute-https-url>&cluster=devnet

Example:

https://dial.to/developer?url=https://<api-id>.execute-api.<region>.amazonaws.com/api/donate&cluster=devnet

Donate Blink

You will see a warning that the actions has not yet been registered. That is normal as Dialect requires Blinks to be registered first before using it on different websites for security purposes.

Creating the POST donate endpoint

Now that we have the GET and OPTIONS endpoints, let's create the POST endpoint that will handle the donation.

export const post: Handler = async (event: APIGatewayProxyEvent, context) => {
  const amount = event.pathParameters?.amount ?? DEFAULT_DONATION_AMOUNT_SOL.toString();

  const body = await JSON.parse(event.body || '{}');
  let account;

  try {
    account = new PublicKey(body.account);
  } catch (error) {
    return {
      statusCode: 400,
      body: 'Invalid account',
      headers: ACTIONS_CORS_HEADERS,
    };
  }

  const parsedAmount = parseFloat(amount);
  const transaction = await prepareDonateTransaction(
    new PublicKey(account),
    new PublicKey(DONATION_DESTINATION_WALLET),
    parsedAmount * LAMPORTS_PER_SOL,
  );

  const response = await createPostResponse({
    fields: {
      type: 'transaction',
      transaction: transaction,
    },
  });

  return {
    statusCode: 200,
    body: JSON.stringify(response),
    headers: ACTIONS_CORS_HEADERS,
  };
};
Enter fullscreen mode Exit fullscreen mode

Code walkthrough

  • We first get the amount from the URL path parameters. If it is not present, we use the default donation amount (1 SOL).
  • We then parse the body of the request to get the account of the user.
  • After that, we prepare the transaction using the prepareDonateTransaction function.
  • The prepareDonateTransaction function is a custom function that prepares the transaction to send the donation to the wallet address. For further details, check the docs here.
// ./src/util.ts

import {
  PublicKey,
  SystemProgram,
  TransactionInstruction,
  TransactionMessage,
  VersionedTransaction,
} from '@solana/web3.js';
import { clusterApiUrl, Connection } from '@solana/web3.js';

export const connection = new Connection(process.env.SOLANA_RPC! || clusterApiUrl('devnet'));

async function prepareTransaction(instructions: TransactionInstruction[], payer: PublicKey) {
  const blockhash = await connection.getLatestBlockhash({ commitment: 'max' }).then(res => res.blockhash);
  const messageV0 = new TransactionMessage({
    payerKey: payer,
    recentBlockhash: blockhash,
    instructions,
  }).compileToV0Message();
  return new VersionedTransaction(messageV0);
}

export async function prepareDonateTransaction(
  sender: PublicKey,
  recipient: PublicKey,
  lamports: number,
): Promise<VersionedTransaction> {
  const instructions = [
    SystemProgram.transfer({
      fromPubkey: sender,
      toPubkey: new PublicKey(recipient),
      lamports: lamports,
    }),
  ];
  return prepareTransaction(instructions, sender);
}
Enter fullscreen mode Exit fullscreen mode
  • We then create a response using the createPostResponse function.
  • Finally, we return the response with the CORS headers.

Configure the POST endpoint in sst.config.ts

async run() {
  const api = new sst.aws.ApiGatewayV2('Actions');

  api.route('GET /api/donate', {
    handler: 'src/donate.get',
  });
  api.route('OPTIONS /api/donate', {
    handler: 'src/donate.options',
  });
  api.route('POST /api/donate/{amount}', { handler: 'src/donate.post' });
},
Enter fullscreen mode Exit fullscreen mode

What we just did is adding a new API route on api/donate/{amount}

So the API now has three endpoints:

Method url
GET /api/donate
OPTIONS /api/donate
POST /api/donate/{amount}

Run the command

# Development mode
npx sst dev
Enter fullscreen mode Exit fullscreen mode

Test the blink again

You are now able to try and donate to the wallet address you specified.

https://dial.to/developer?url=https://<api-id>.execute-api.<region>.amazonaws.com/api/donate&cluster=devnet

Create the actions.json endpoint

The purpose of the actions.json file allows an application to instruct clients on what website URLs support Solana Actions and provide a mapping that can be used to perform GET requests to an Actions API server.

import { ACTIONS_CORS_HEADERS } from '@solana/actions';
import { APIGatewayProxyEvent, Handler } from 'aws-lambda';

export const get: Handler = async (event: APIGatewayProxyEvent, context) => {
  return {
    statusCode: 200,
    headers: ACTIONS_CORS_HEADERS,
    body: JSON.stringify({
      rules: [{ pathPattern: '/donate', apiPath: '/api/donate' }],
    }),
  };
};

export const options = get;
Enter fullscreen mode Exit fullscreen mode

actions.json endpoint is the same as the donate endpoint. However in actions.json it returns a JSON object that specifies the rules on where to find the actions. You can read more about actions.json here.

Configure actions.json endpoint in sst.config.ts

async run() {
  const api = new sst.aws.ApiGatewayV2('Actions');

  api.route('GET /api/donate', {
    handler: 'src/donate.get',
  });
  api.route('OPTIONS /api/donate', {
    handler: 'src/donate.options',
  });
  api.route('POST /api/donate/{amount}', { handler: 'src/donate.post' });

  api.route('GET /actions.json', { handler: 'src/actions.get' });
  api.route('OPTIONS /actions.json', { handler: 'src/actions.options' });
},
Enter fullscreen mode Exit fullscreen mode

Finally, our API now has five endpoints:

Method url
GET /api/donate
OPTIONS /api/donate
POST /api/donate/{amount}
GET /actions.json
OPTIONS /actions.json

Deploy to Production

Deploying on production with SST is easy. Just run the following command:

npx sst deploy --stage production

Enter fullscreen mode Exit fullscreen mode

It will output a new URL that you can use to test your blink.

Demo

Donating 1 SOL

Demo 1 SOL

Donating a custom amount

Demo custom

Cleanup

Removing the resources is as easy as deploying them. Just run the following command:

npx sst remove # to remove the resources in the development stage
npx sst remove --stage production # to remove the resources in the production stage
Enter fullscreen mode Exit fullscreen mode

Conclusion

And that's it! You've successfully deployed your Blinks on AWS using SST. You can now create more Blinks and deploy them on AWS with ease!

Feel free to create a git repository and push your code to GitHub!

If you have any questions or found any issues, feel free to comment below.

💖 💪 🙅 🚩
denrei
Denrei Keith De Jesus

Posted on November 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Blinks on AWS with SST
aws Blinks on AWS with SST

November 8, 2024