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

Refactor and add tests #13

Merged
merged 4 commits into from
Jan 23, 2024
Merged
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
25 changes: 25 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# From https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs#using-the-nodejs-starter-workflow
name: Node.js and Solana CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20.x]

steps:
- uses: actions/checkout@v3

# Install everything
- run: npm ci

# Run tests
- run: npm test
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ next-env.d.ts

# database files
walletData.json
ipData.json
ipData.json
.env
test-ledger
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
Solana Devnet Faucet with rate limiting
# Solana Devnet Faucet with rate limiting

This is the code for the [Solana Devnet Faucet](https://faucet.solana.com/)

## Run tests

```
npm run test
```

Or to run a single test, for example:

```
npx jest -t 'is a PDA'
```

## Run locally for development

You'll need an `.env` file with:

```
FAUCET_KEYPAIR=[numbers...]
POSTGRES_STRING="some string"
mikemaccana marked this conversation as resolved.
Show resolved Hide resolved
```

And then run:

```
npm run dev
```

## Deploy

Deploying is done automatically as soon as the code is committed onto master via Vercel.

Vercel also needs these details:

```
RPC_URL: "string"
CLOUDFLARE_SECRET: "string"
```
9 changes: 7 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default function Home() {
});
const [showVerifyDialog, setShowVerifyDialog] = useState<boolean>(false);
const toaster = useToast();
const [network, setSelectedNetwork] = useState('devnet');
const [network, setSelectedNetwork] = useState("devnet");
Copy link
Contributor Author

@mikemaccana mikemaccana Jan 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just linting, run automatically on save.


const validateWallet = (address: string): boolean => {
try {
Expand Down Expand Up @@ -91,7 +91,12 @@ export default function Home() {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ walletAddress, amount, cloudflareCallback, network: network }),
body: JSON.stringify({
walletAddress,
amount,
cloudflareCallback,
network: network,
}),
});

if (res.ok) {
Expand Down
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
File renamed without changes.
55 changes: 55 additions & 0 deletions lib/db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "@jest/globals";
import { Row, checkLimits } from "./db";
import { Pool } from "pg";
import { MINUTES } from "./constants";
const log = console.log;

let mockRows: Array<Row> = [];

// Make a mock for the pg Pool constructor
// https://jestjs.io/docs/mock-functions#mocking-modules
jest.mock("pg", () => {
return {
Pool: jest.fn(() => ({
query: jest.fn(() => {
return {
rows: mockRows,
};
}),
})),
};
});

describe("checkLimits", () => {
// TODO: ideally I'd like to use mockValueOnce() instead of the
// mockRows variable, but I couldn't get it to work.
test("is fine when there's no previous usage", async () => {
mockRows = [];
await checkLimits("1.1.1.1");
});

test("allows reasonable usage", async () => {
mockRows = [
{
timestamps: [Date.now() - 10 * MINUTES],
},
];
await checkLimits("1.1.1.1");
});

test("blocks unreasonable usage", async () => {
mockRows = [
{
timestamps: [
Date.now() - 10 * MINUTES,
Date.now() - 10 * MINUTES,
Date.now() - 10 * MINUTES,
Date.now() - 10 * MINUTES,
],
},
];
await expect(checkLimits("1.1.1.1")).rejects.toThrow(
"You have exceeded the 2 airdrops limit in the past 1 hour(s)"
);
});
});
62 changes: 62 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Pool } from "pg";
import { HOURS } from "./constants";
const log = console.log;

export interface Row {
timestamps: Array<number>;
}

const pgClient = new Pool({
connectionString: process.env.POSTGRES_STRING as string,
});

// Eg if AIRDROPS_LIMIT_TOTAL is 2, and AIRDROPS_LIMIT_HOURS is 1,
// then a user can only get 2 airdrops per 1 hour.
const AIRDROPS_LIMIT_TOTAL = 2;
const AIRDROPS_LIMIT_HOURS = 1;

// Formerly called 'getOrCreateAndVerifyDatabaseEntry'
export const checkLimits = async (
ipAddressWithoutDotsOrWalletAddress: string
): Promise<void> => {
// Remove the . (IPV4) and : (IPV6) from the IP address
let databaseKey = ipAddressWithoutDotsOrWalletAddress.replace(/[\.,:]/g, "");

const entryQuery = "SELECT * FROM rate_limits WHERE key = $1;";
const insertQuery =
"INSERT INTO rate_limits (key, timestamps) VALUES ($1, $2);";
const updateQuery = "UPDATE rate_limits SET timestamps = $2 WHERE key = $1;";

const timeAgo = Date.now() - AIRDROPS_LIMIT_HOURS * HOURS;

const queryResult = await pgClient.query(entryQuery, [databaseKey]);

const rows = queryResult.rows as Array<Row>;
const entry = rows[0];

if (entry) {
const timestamps = entry.timestamps;

const isExcessiveUsage =
timestamps.filter((timestamp: number) => timestamp > timeAgo).length >=
AIRDROPS_LIMIT_TOTAL;

if (isExcessiveUsage) {
throw new Error(
`You have exceeded the ${AIRDROPS_LIMIT_TOTAL} airdrops limit in the past ${AIRDROPS_LIMIT_HOURS} hour(s)`
);
}

timestamps.push(Date.now());

await pgClient.query(updateQuery, [
ipAddressWithoutDotsOrWalletAddress,
timestamps,
]);
} else {
await pgClient.query(insertQuery, [
ipAddressWithoutDotsOrWalletAddress,
[Date.now()],
]);
}
};
35 changes: 35 additions & 0 deletions lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextApiRequest } from "next";
import { getHeaderValues } from "./utils";

describe("getHeaderValues", () => {
it("returns an array of strings when x-forwarded-for is a string", () => {
const req = {
headers: {
"x-forwarded-for": "10.0.0.0",
},
} as unknown as NextApiRequest;
const headerName = "x-forwarded-for";
const result = getHeaderValues(req, headerName);
expect(result).toEqual(["10.0.0.0"]);
});

it("returns an array of strings when x-forwarded-for is an array", () => {
const req = {
headers: {
"x-forwarded-for": ["10.0.0.0"],
},
} as unknown as NextApiRequest;
const headerName = "x-forwarded-for";
const result = getHeaderValues(req, headerName);
expect(result).toEqual(["10.0.0.0"]);
});

it("returns an array of strings when x-forwarded-for does not exist", () => {
const req = {
headers: {},
} as unknown as NextApiRequest;
const headerName = "x-forwarded-for";
const result = getHeaderValues(req, headerName);
expect(result).toEqual([]);
});
});
11 changes: 11 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { type ClassValue, clsx } from "clsx";
import { NextApiRequest } from "next";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export const getHeaderValues = (req: NextApiRequest, headerName: string) => {
// Annoyingly, req.headers["x-forwarded-for"] can be a string or an array of strings
// Let's just make it an array of strings
let valueOrValues = req.headers[headerName] || [];
if (Array.isArray(valueOrValues)) {
return valueOrValues;
}
return [valueOrValues];
};
47 changes: 47 additions & 0 deletions lib/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { validate } from "./validate";

const WALLET_ADDRESS_FOR_TESTS = "dDCQNnDmNbFVi8cQhKAgXhyhXeJ625tvwsunRyRc7c8";
const PDA_ADDRESS_FOR_TESTS = "4MD31b2GFAWVDYQT8KG7E5GcZiFyy4MpDUt4BcyEdJRP";

// Write some tests for the validate function
describe("validate", () => {
test("allows reasonable usage", () => {
validate("DXJfhtWicZwBpHGiBepWwwnJK7jJYNYguGDUgNYbMCCi", 1);
});

test("throws when wallet address is a PDA", () => {
expect(() => {
validate(PDA_ADDRESS_FOR_TESTS, 1);
}).toThrow("Please enter valid wallet address.");
});

test("throws when wallet address is empty string", () => {
expect(() => {
validate("", 1);
}).toThrow("Missing wallet address.");
});

test("throws when amount is 0", () => {
expect(() => {
validate(WALLET_ADDRESS_FOR_TESTS, 0);
}).toThrow("Missing SOL amount.");
});

test("throws when amount is negative", () => {
expect(() => {
validate(WALLET_ADDRESS_FOR_TESTS, -3);
}).toThrow("Requested SOL amount cannot be negative.");
});

test("throws when amount is too large", () => {
expect(() => {
validate(WALLET_ADDRESS_FOR_TESTS, 6);
}).toThrow("Requested SOL amount too large.");
});

test("throws when wallet address is invalid", () => {
expect(() => {
validate("invalidWalletAddress", 1);
}).toThrow("Please enter valid wallet address.");
});
});
31 changes: 31 additions & 0 deletions lib/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { PublicKey } from "@solana/web3.js";

const MAX_SOL_AMOUNT = 5;

export const validate = (walletAddress: string, amount: number): void => {
if (!walletAddress) {
throw new Error("Missing wallet address.");
}

if (!amount) {
throw new Error("Missing SOL amount.");
}

if (amount < 0) {
throw new Error("Requested SOL amount cannot be negative.");
}

if (amount > MAX_SOL_AMOUNT) {
mikemaccana marked this conversation as resolved.
Show resolved Hide resolved
throw new Error("Requested SOL amount too large.");
}

try {
let pubkey = new PublicKey(walletAddress);
let isOnCurve = PublicKey.isOnCurve(pubkey.toBuffer());
if (!isOnCurve) {
throw new Error("Address can't be a PDA.");
}
} catch (error) {
throw new Error("Please enter valid wallet address.");
}
};
Loading