Skip to content

Commit

Permalink
Feat/add backend lambda functions (#20)
Browse files Browse the repository at this point in the history
* Add initial deps for AWS lambda functions

* Add hello world for testing

* Add discord function|Use dummy IDL

* Add todos

* Run prettier

* Run frontend workflow only for frontend folder

* Make changes to frontend workflow

* Fix type issue

* Fix pre-commit github workflow in CI

* single index for lambdas

* Add fundTransaction implementation

* Use prettier same as frontend

* Minor fix

* Add validate fund txn request body

* Fix pre-commit hook in CI

* backend: adding integration test (#27)

* use swcrc

* jest cfg in package json

* backend gh action

* discord sign integration test

* BE: serve lambdas locally + validation fixes (#32)

* local lambda server + move process.env to config file

* program id as cfg instead of secret

* simple readme

* fix input validation for discord-signed-digest

* fund-transactions test

* fix fund txs validation

* Update IDL json

* Minor updates|Downgrade prettier

* include json in build

* readme prettier

---------

Co-authored-by: matias martinez <matias@xlabs.xyz>
Co-authored-by: Matías Martínez <131624652+mat1asm@users.noreply.github.com>
  • Loading branch information
3 people authored Mar 25, 2024
1 parent fb4f8ae commit 7de14d5
Show file tree
Hide file tree
Showing 24 changed files with 7,001 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Backend Tests
on:
pull_request:
paths: [backend/**]
push:
branches: [main]
paths: [backend/**]

jobs:
test:
runs-on: ubuntu-latest

defaults:
run:
working-directory: ./backend

steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
cache: yarn
cache-dependency-path: backend/yarn.lock
- name: Install deps
run: yarn install --frozen-lockfile
- name: Prettier check
run: yarn prettier:check
- name: Lint
run: yarn lint
- name: Build
run: yarn build
- name: Test
run: yarn test
2 changes: 2 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: Frontend Tests
on:
pull_request:
paths: [frontend/**]
push:
branches: [main]
paths: [frontend/**]

jobs:
test:
Expand Down
12 changes: 12 additions & 0 deletions backend/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "script"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"rules": {
"no-undef": "off"
}
}
41 changes: 41 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build
/dist

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# env
.env

# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local

# vercel
.vercel

# typescript
*.tsbuildinfo
5 changes: 5 additions & 0 deletions backend/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}
14 changes: 14 additions & 0 deletions backend/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"minify": false,
"jsc": {
"target": "es2016",
"parser": {
"syntax": "typescript",
"preserveAllComments": false
}
},
"module": {
"type": "commonjs",
"strict": true
}
}
31 changes: 31 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Backend

This module contains two functions meant to be executed as AWS Lambda functions.

## Run locally

Set some env vars:

```bash
export DISPENSER_WALLET_KEY = [your, solana, private, key]
export FUNDING_WALLET_KEY = [your, solana, private, key]
```

Then run:

```bash
yarn serve
```

Will expose two endpoints on localhost:8002:

`GET /api/grant/v1/discord_signed_message` => signDiscordMessageHandler
`POST /api/grant/v1/fund_transaction` => fundTransactionHandler

## Deployment environment variables

Following env vars are required:

- `DISPENSER_KEY_SECRET_NAME`: private key of the wallet that will be used to sign the discord message
- `FUNDER_WALLET_KEY_SECRET_NAME`: private key of the wallet that will be used to fund the transactions
- `TOKEN_DISPENSER_PROGRAM_ID`: the program id of the token dispenser
46 changes: 46 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "tsc --noemit && rm -rf dist && swc ./src -d ./dist --copy-files --ignore \"**/*.test.ts,**/__test__/**\"",
"lint": "eslint \"src/**/*.{json,js,jsx,ts,tsx}\" && tsc --noemit",
"prettier": "prettier \"./**/*.{json,js,jsx,ts,tsx}\" --write",
"prettier:check": "prettier \"./**/*.{json,js,jsx,ts,tsx}\" --check",
"test": "jest",
"test:coverage": "jest --coverage",
"serve": "node -r @swc-node/register src/serve.ts"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.535.0",
"@coral-xyz/anchor": "^0.29.0",
"@solana/web3.js": "^1.91.1",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@swc-node/register": "^1.9.0",
"@swc/cli": "^0.3.10",
"@swc/core": "^1.4.8",
"@swc/jest": "^0.2.36",
"@swc/types": "^0.1.6",
"@types/aws-lambda": "^8.10.136",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"body-parser": "^1.20.2",
"eslint": "^8.57.0",
"express": "^4.19.1",
"jest": "^29.7.0",
"msw": "^2.2.9",
"prettier": "^2.7.1",
"typescript": "^5.4.2"
},
"jest": {
"transform": {
"^.+\\.(t|j)sx?$": "@swc/jest"
}
}
}
26 changes: 26 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export default {
discord: {
baseUrl: process.env.DISCORD_URL ?? 'https://discord.com'
},
aws: {
region: process.env.AWS_REGION ?? 'us-east-2'
},
tokenDispenserProgramId: () => process.env.TOKEN_DISPENSER_PROGRAM_ID,
keys: {
dispenserGuard: {
/** optional. mostly for local testing */
key: process.env.DISPENSER_WALLET_KEY,
/** required. with a default value and used when when key not set */
secretName:
process.env.DISPENSER_KEY_SECRET_NAME ?? 'xl-dispenser-guard-key'
},
funding: {
/** optional. mostly for local testing */
key: process.env.FUNDING_WALLET_KEY,
/** required. with a default value and used when when key not set */
secretName:
process.env.FUNDER_WALLET_KEY_SECRET_NAME ??
'xli-test-secret-funder-wallet'
}
}
}
94 changes: 94 additions & 0 deletions backend/src/handlers/discord-signed-digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Keypair, PublicKey } from '@solana/web3.js'
import { getDispenserKey } from '../utils/secrets'
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
import { getDiscordUser, signDiscordDigest } from '../utils/discord'
import { HandlerError } from '../utils/errors'

export interface DiscordSignedDigestParams {
publicKey: string
}

export const signDiscordMessage = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
try {
const publicKey = (event.queryStringParameters ?? {})['publicKey']
validatePublicKey(publicKey)

const accessToken = event.headers['x-auth-token']
const discordId = await getDiscordId(accessToken)

const claimant = new PublicKey(publicKey!)
const dispenserGuard = await loadDispenserGuard()

const signedDigest = signDiscordDigest(discordId, claimant, dispenserGuard)

return {
statusCode: 200,
body: JSON.stringify({
signature: Buffer.from(signedDigest.signature).toString('hex'),
publicKey: Buffer.from(signedDigest.publicKey).toString('hex'), // The dispenser guard's public key
fullMessage: Buffer.from(signedDigest.fullMessage).toString('hex')
})
}
} catch (err: HandlerError | unknown) {
console.error('Error generating signed discord digest', err)
if (err instanceof HandlerError) {
return {
statusCode: err.statusCode,
body: JSON.stringify(err.body)
}
}

return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
}
}
}

async function loadDispenserGuard() {
const secretData = await getDispenserKey()
const dispenserGuardKey = secretData.key

const dispenserGuard = Keypair.fromSecretKey(
Uint8Array.from(dispenserGuardKey)
)

return dispenserGuard
}

function validatePublicKey(publicKey?: string) {
if (!publicKey) {
throw new HandlerError(400, {
error: "Must provide the 'publicKey' query parameter"
})
}

if (typeof publicKey !== 'string') {
throw new HandlerError(400, {
error: "Invalid 'publicKey' query parameter"
})
}

try {
new PublicKey(publicKey)
} catch {
throw new HandlerError(400, {
error: "Invalid 'publicKey' query parameter"
})
}
}

async function getDiscordId(accessToken?: string) {
if (!accessToken) {
throw new HandlerError(400, { error: 'Must provide discord auth token' })
}

try {
const user = await getDiscordUser(accessToken)
return user.id
} catch (err) {
throw new HandlerError(403, { error: 'Invalid discord access token' })
}
}
72 changes: 72 additions & 0 deletions backend/src/handlers/fund-transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
import { getFundingKey } from '../utils/secrets'
import {
checkTransactions,
deserializeTransactions
} from '../utils/fund-transactions'
import { Keypair } from '@solana/web3.js'
import { HandlerError } from '../utils/errors'

export type FundTransactionRequest = Uint8Array[]

export const fundTransactions = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
try {
const requestBody = JSON.parse(event.body!)
validateFundTransactions(requestBody)
const transactions = deserializeTransactions(requestBody)
const isTransactionsValid = await checkTransactions(transactions)

if (!isTransactionsValid) {
return {
statusCode: 403,
body: JSON.stringify({ error: 'Unauthorized transactions' })
}
}

const wallet = await loadFunderWallet()

const signedTransactions = await wallet.signAllTransactions(transactions)
return {
statusCode: 200,
body: JSON.stringify(
signedTransactions.map((tx) => Buffer.from(tx.serialize()))
)
}
} catch (err: HandlerError | unknown) {
console.error('Error signing transactions', err)

if (err instanceof HandlerError) {
return {
statusCode: err.statusCode,
body: JSON.stringify(err.body)
}
}

return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
}
}
}

function validateFundTransactions(transactions: unknown) {
if (!Array.isArray(transactions) || transactions.length === 0) {
throw new HandlerError(400, { error: 'Must provide transactions' })
}

if (transactions.length >= 10) {
throw new HandlerError(400, { error: 'Too many transactions' })
}
}

async function loadFunderWallet(): Promise<NodeWallet> {
const secretData = await getFundingKey()
const funderWalletKey = secretData.key

const keypair = Keypair.fromSecretKey(new Uint8Array(funderWalletKey))

return new NodeWallet(keypair)
}
Loading

0 comments on commit 7de14d5

Please sign in to comment.