Skip to content

Commit

Permalink
add core library to handle most of the logic, move functions from ver…
Browse files Browse the repository at this point in the history
…cel to next
  • Loading branch information
sevazhidkov committed Mar 4, 2022
1 parent fd05065 commit 5301e93
Show file tree
Hide file tree
Showing 27 changed files with 638 additions and 147 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ keys
!.env.example

node_modules/

.next
next-env.d.ts
1 change: 0 additions & 1 deletion api/blockhash.js

This file was deleted.

1 change: 0 additions & 1 deletion api/index.js

This file was deleted.

1 change: 0 additions & 1 deletion api/transfer.js

This file was deleted.

2 changes: 0 additions & 2 deletions build/.gitignore

This file was deleted.

19 changes: 11 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,26 @@
"access": "public"
},
"scripts": {
"clean": "shx rm -rf build/*",
"build": "yarn clean && esbuild --bundle --platform=node --target=node14 --outdir=build --sourcemap --legal-comments=none `find src/api \\\\( -name '*.ts' \\\\)`",
"vercel-build": "yarn run build --minify",
"local": "vercel dev",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint && prettier --check '{*,**/*}.{js,ts,jsx,tsx,json}'",
"lint:fix": "eslint --fix --ext .ts . && yarn fmt",
"test": "mocha",
"fmt": "prettier --write '{*,**/*}.{js,ts,jsx,tsx,json}'",
"lint": "eslint --ext .ts . && prettier --check '{*,**/*}.{js,ts,jsx,tsx,json}'",
"lint:fix": "eslint --fix --ext .ts . && yarn fmt",
"nuke": "shx rm -rf node_modules yarn.lock"
},
"dependencies": {
"@solana/buffer-layout": "^4.0.0",
"@solana/spl-token": "^0.2.0-alpha.1",
"@solana/spl-token": "^0.2.0",
"@solana/web3.js": "^1.31.0",
"bs58": "^4.0.1",
"cache-manager": "^3.6.0",
"cors": "^2.8.5",
"express-rate-limit": "^5.5.1"
"express-rate-limit": "^5.5.1",
"next": "^12.1.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@types/bs58": "^4.0.1",
Expand All @@ -43,6 +45,7 @@
"@types/mocha": "^9.0.0",
"@types/node": "^16.11.13",
"@types/prettier": "^2.4.2",
"@types/react": "^17.0.39",
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
"@vercel/node": "^1.12.1",
Expand Down
12 changes: 12 additions & 0 deletions pages/api/blockhash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { connection } from '../../src/helpers';
import { rateLimit } from '../../src/middleware';

// Endpoint to get the most recent blockhash seen by Octane's RPC node
export default async function (request: NextApiRequest, response: NextApiResponse) {
await rateLimit(request, response);

const blockhash = await connection.getRecentBlockhash();

response.status(200).send({ blockhash });
}
16 changes: 16 additions & 0 deletions pages/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import config from '../../config.json';
import { ENV_FEE_PAYER } from '../../src/helpers';
import { rateLimit } from '../../src/middleware';

const body = {
feePayer: ENV_FEE_PAYER.toBase58(),
...config,
};

// Endpoint to get Octane's configuration
export default async function (request: NextApiRequest, response: NextApiResponse) {
await rateLimit(request, response);

response.status(200).send(body);
}
53 changes: 53 additions & 0 deletions pages/api/transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { PublicKey, Transaction } from '@solana/web3.js';
import type { NextApiRequest, NextApiResponse } from 'next';
import base58 from 'bs58';
import { signWithTokenFee } from '../../src';
import { cache, connection, ENV_SECRET_KEYPAIR } from '../../src/helpers';
import { cors, rateLimit } from '../../src/middleware';
import config from '../../config.json';

// Endpoint to pay for transactions with an SPL token transfer
export default async function (request: NextApiRequest, response: NextApiResponse) {
await cors(request, response);
await rateLimit(request, response);

// Deserialize a base58 wire-encoded transaction from the request
const serialized = request.body?.transaction;
if (typeof serialized !== 'string') {
response.status(400).send({ status: 'error', message: 'request should contain transaction' });
return;
}

let transaction: Transaction;
try {
transaction = Transaction.from(base58.decode(serialized));
} catch (e) {
response.status(400).send({ status: 'error', message: "can't decode transaction" });
return;
}

try {
const { signature } = await signWithTokenFee(
connection,
transaction,
ENV_SECRET_KEYPAIR,
config.maxSignatures,
config.lamportsPerSignature,
config.endpoints.transfer.tokens.map((token) => ({
mint: new PublicKey(token.mint),
account: new PublicKey(token.account),
decimals: token.decimals,
fee: BigInt(token.fee),
})),
cache
);
// Respond with the confirmed transaction signature
response.status(200).send({ status: 'ok', signature });
} catch (error) {
let message: string = '';
if (error instanceof Error) {
message = error.message;
}
response.status(400).send({ status: 'error', message });
}
}
Empty file removed public/index.html
Empty file.
1 change: 1 addition & 0 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './signIfTokenFeePaid';
64 changes: 64 additions & 0 deletions src/actions/signIfTokenFeePaid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Transaction, Connection, Keypair } from '@solana/web3.js';
import type { Cache } from 'cache-manager';
import base58 from 'bs58';
import { sha256, simulateRawTransaction, validateTransaction, validateTransfer, AllowedToken } from '../core';

/**
* Sign transaction by fee payer if the first instruction is a transfer of token fee to given account
*
* @param connection Connection to a Solana node
* @param transaction Transaction to sign
* @param maxSignatures Maximum allowed signatures in the transaction including fee payer's
* @param lamportsPerSignature Maximum fee payment in lamports
* @param allowedTokens List of tokens that can be used with token fee receiver accounts and fee details
* @param feePayer Keypair for fee payer
* @param cache A cache to store duplicate transactions
*
* @return {signature: string} Transaction signature by fee payer
*/
export async function signWithTokenFee(
connection: Connection,
transaction: Transaction,
feePayer: Keypair,
maxSignatures: number,
lamportsPerSignature: number,
allowedTokens: AllowedToken[],
cache: Cache
): Promise<{ signature: string }> {
// Prevent simple duplicate transactions using a hash of the message
let key = `transaction/${base58.encode(sha256(transaction.serializeMessage()))}`;
if (await cache.get(key)) throw new Error('duplicate transaction');
await cache.set(key, true);

// Check that the transaction is basically valid, sign it, and serialize it, verifying the signatures
const { signature, rawTransaction } = await validateTransaction(
connection,
transaction,
feePayer,
maxSignatures,
lamportsPerSignature
);

// Check that the transaction contains a valid transfer to Octane's token account
const transfer = await validateTransfer(connection, transaction, allowedTokens);

/*
An attacker could make multiple signing requests before the transaction is confirmed. If the source token account
has the minimum fee balance, validation and simulation of all these requests may succeed. All but the first
confirmed transaction will fail because the account will be empty afterward. To prevent this race condition,
simulation abuse, or similar attacks, we implement a simple lockout for the source token account until the
transaction succeeds or fails.
*/
key = `transfer/${transfer.keys.source.pubkey.toBase58()}`;
if (await cache.get(key)) throw new Error('duplicate transfer');
await cache.set(key, true);

try {
// Simulate, send, and confirm the transaction
await simulateRawTransaction(connection, rawTransaction);
} finally {
await cache.del(key);
}

return { signature: signature };
}
12 changes: 0 additions & 12 deletions src/api/blockhash.ts

This file was deleted.

16 changes: 0 additions & 16 deletions src/api/index.ts

This file was deleted.

49 changes: 0 additions & 49 deletions src/api/transfer.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
export * from './cache';
export * from './connection';
export * from './env';
export * from './sha256';
export * from './simulateRawTransaction';
export * from './validateTransaction';
Expand Down
4 changes: 2 additions & 2 deletions src/core/simulateRawTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { PublicKey, SimulatedTransactionResponse, Transaction } from '@solana/web3.js';
import { connection } from './connection';
import { Connection, PublicKey, SimulatedTransactionResponse, Transaction } from '@solana/web3.js';

// Simulate a signed, serialized transaction before broadcasting
export async function simulateRawTransaction(
connection: Connection,
rawTransaction: Buffer,
includeAccounts?: boolean | Array<PublicKey>
): Promise<SimulatedTransactionResponse> {
Expand Down
23 changes: 12 additions & 11 deletions src/core/validateTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import { Transaction, TransactionSignature } from '@solana/web3.js';
import { Connection, Transaction, TransactionSignature, Keypair } from '@solana/web3.js';
import base58 from 'bs58';
import config from '../../config.json';
import { connection } from './connection';
import { ENV_FEE_PAYER, ENV_SECRET_KEYPAIR } from './env';

// Check that a transaction is basically valid, sign it, and serialize it, verifying the signatures
export async function validateTransaction(
transaction: Transaction
connection: Connection,
transaction: Transaction,
feePayer: Keypair,
maxSignatures: number,
lamportsPerSignature: number
): Promise<{ signature: TransactionSignature; rawTransaction: Buffer }> {
// Check the fee payer and blockhash for basic validity
if (!transaction.feePayer?.equals(ENV_FEE_PAYER)) throw new Error('invalid fee payer');
if (!transaction.feePayer?.equals(feePayer.publicKey)) throw new Error('invalid fee payer');
if (!transaction.recentBlockhash) throw new Error('missing recent blockhash');

// TODO: handle nonce accounts?

// Check Octane's RPC node for the blockhash to make sure it's synced and the fee is reasonable
const feeCalculator = await connection.getFeeCalculatorForBlockhash(transaction.recentBlockhash);
if (!feeCalculator.value) throw new Error('blockhash not found');
if (feeCalculator.value.lamportsPerSignature > config.lamportsPerSignature) throw new Error('fee too high');
if (feeCalculator.value.lamportsPerSignature > lamportsPerSignature) throw new Error('fee too high');

// Check the signatures for length, the primary signature, and secondary signature(s)
if (!transaction.signatures.length) throw new Error('no signatures');
if (transaction.signatures.length > config.maxSignatures) throw new Error('too many signatures');
if (transaction.signatures.length > maxSignatures) throw new Error('too many signatures');

const [primary, ...secondary] = transaction.signatures;
if (!primary.publicKey.equals(ENV_FEE_PAYER)) throw new Error('invalid fee payer pubkey');
if (!primary.publicKey.equals(feePayer.publicKey)) throw new Error('invalid fee payer pubkey');
if (primary.signature) throw new Error('invalid fee payer signature');

for (const signature of secondary) {
Expand All @@ -35,13 +36,13 @@ export async function validateTransaction(
// Prevent draining by making sure that the fee payer isn't provided as writable or a signer to any instruction
for (const instruction of transaction.instructions) {
for (const key of instruction.keys) {
if ((key.isWritable || key.isSigner) && key.pubkey.equals(ENV_FEE_PAYER))
if ((key.isWritable || key.isSigner) && key.pubkey.equals(feePayer.publicKey))
throw new Error('invalid account');
}
}

// Add the fee payer signature
transaction.partialSign(ENV_SECRET_KEYPAIR);
transaction.partialSign(feePayer);

// Serialize the transaction, verifying the signatures
const rawTransaction = transaction.serialize();
Expand Down
Loading

0 comments on commit 5301e93

Please sign in to comment.