Endpoint/action to create associated token accounts (#17)

* add an action to create associated token accounts

* fix linter issues

* add transaction submission to create account endpoint

* rename /createAccount to createAssociatedTokenAccount to reduce ambiguity

* add createAssociatedTokenAccount section to config
This commit is contained in:
Seva Zhidkov 2022-09-09 00:50:07 +03:00 committed by GitHub
parent 6d45f42dfd
commit 2dbbe9c0b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 389 additions and 9 deletions

View File

@ -14,6 +14,16 @@
}
]
},
"createAssociatedTokenAccount": {
"tokens": [
{
"mint": "__PLACEHOLDER__",
"account": "__PLACEHOLDER__",
"decimals": 9,
"fee": 10000000
}
]
},
"whirlpoolsSwap": {
"tokens": [
{

View File

@ -0,0 +1,68 @@
import { Connection, Keypair, Transaction } from '@solana/web3.js';
import {
AllowedToken,
sha256,
simulateRawTransaction,
validateAccountInitializationInstructions,
validateTransaction,
validateTransfer,
} from '../core';
import { Cache } from 'cache-manager';
import base58 from 'bs58';
/**
* Sign transaction by fee payer if the first instruction is a transfer of a fee to given account and the second instruction
* creates an associated token account with initialization fees by fee payer.
*
* @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 transaction 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
* @param sameSourceTimeout An interval for transactions with same token fee source, ms
*
* @return {signature: string} Transaction signature by fee payer
*/
export async function createAccountIfTokenFeePaid(
connection: Connection,
transaction: Transaction,
feePayer: Keypair,
maxSignatures: number,
lamportsPerSignature: number,
allowedTokens: AllowedToken[],
cache: Cache,
sameSourceTimeout = 5000
) {
// 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 transaction only contains transfer and a valid new account
await validateAccountInitializationInstructions(connection, transaction, feePayer, cache);
// Check that the transaction contains a valid transfer to Octane's token account
const transfer = await validateTransfer(connection, transaction, allowedTokens);
key = `createAccount/lastSignature/${transfer.keys.source.pubkey.toBase58()}`;
const lastSignature: number | undefined = await cache.get(key);
if (lastSignature && Date.now() - lastSignature < sameSourceTimeout) {
throw new Error('duplicate transfer');
}
await cache.set(key, Date.now());
await simulateRawTransaction(connection, rawTransaction);
return { signature: signature };
}

View File

@ -1,3 +1,4 @@
export * from './buildWhirlpoolsSwapToSOL';
export * from './signGeneratedTransaction';
export * from './signIfTokenFeePaid';
export * from './createAccountIfTokenFeePaid';

View File

@ -1,7 +1,14 @@
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';
import {
sha256,
simulateRawTransaction,
validateTransaction,
validateTransfer,
AllowedToken,
validateInstructions,
} from '../core';
/**
* Sign transaction by fee payer if the first instruction is a transfer of token fee to given account
@ -41,6 +48,8 @@ export async function signWithTokenFee(
lamportsPerSignature
);
await validateInstructions(transaction, feePayer);
// Check that the transaction contains a valid transfer to Octane's token account
const transfer = await validateTransfer(connection, transaction, allowedTokens);

View File

@ -2,5 +2,7 @@ export * from './clusters';
export * from './messageToken';
export * from './sha256';
export * from './simulateRawTransaction';
export * from './validateAccountInitialization';
export * from './validateInstructions';
export * from './validateTransaction';
export * from './validateTransfer';

View File

@ -0,0 +1,14 @@
import { TransactionInstruction } from '@solana/web3.js';
export function areInstructionsEqual(instruction1: TransactionInstruction, instruction2: TransactionInstruction) {
return (
instruction1.data.equals(instruction2.data) &&
instruction1.programId.equals(instruction2.programId) &&
instruction1.keys.every(
(key1, i) =>
key1.pubkey.equals(instruction2.keys[i].pubkey) &&
key1.isWritable === instruction2.keys[i].isWritable &&
key1.isSigner === instruction2.keys[i].isSigner
)
);
}

View File

@ -0,0 +1,52 @@
import { Connection, Transaction, Keypair } from '@solana/web3.js';
import {
ASSOCIATED_TOKEN_PROGRAM_ID,
createAssociatedTokenAccountInstruction,
getAssociatedTokenAddress,
} from '@solana/spl-token';
import { Cache } from 'cache-manager';
import { areInstructionsEqual } from './instructions';
export async function validateAccountInitializationInstructions(
connection: Connection,
originalTransaction: Transaction,
feePayer: Keypair,
cache: Cache
): Promise<void> {
const transaction = Transaction.from(originalTransaction.serialize({ requireAllSignatures: false }));
// Transaction instructions should be: [fee transfer, account initialization]
// The fee transfer is validated with validateTransfer in the action function.
if (transaction.instructions.length != 2) {
throw new Error('transaction should contain 2 instructions: fee payment, account init');
}
const [, instruction] = transaction.instructions;
if (!instruction.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) {
throw new Error('account instruction should call associated token program');
}
const [, , ownerMeta, mintMeta] = instruction.keys;
const associatedToken = await getAssociatedTokenAddress(mintMeta.pubkey, ownerMeta.pubkey);
// Check if account isn't already created
if (await connection.getAccountInfo(associatedToken, 'confirmed')) {
throw new Error('account already exists');
}
const referenceInstruction = createAssociatedTokenAccountInstruction(
feePayer.publicKey,
associatedToken,
ownerMeta.pubkey,
mintMeta.pubkey
);
if (!areInstructionsEqual(referenceInstruction, instruction)) {
throw new Error('unable to match associated account instruction');
}
// Prevent trying to create same accounts too many times within a short timeframe (per one recent blockhash)
const key = `account/${transaction.recentBlockhash}_${associatedToken.toString()}`;
if (await cache.get(key)) throw new Error('duplicate account within same recent blockhash');
await cache.set(key, true);
}

View File

@ -0,0 +1,12 @@
import { Keypair, Transaction } from '@solana/web3.js';
// Prevent draining by making sure that the fee payer isn't provided as writable or a signer to any instruction.
// Throws an error if transaction contain instructions that could potentially drain fee payer.
export async function validateInstructions(transaction: Transaction, feePayer: Keypair): Promise<void> {
for (const instruction of transaction.instructions) {
for (const key of instruction.keys) {
if ((key.isWritable || key.isSigner) && key.pubkey.equals(feePayer.publicKey))
throw new Error('invalid account');
}
}
}

View File

@ -2,6 +2,8 @@ import { Connection, Transaction, TransactionSignature, Keypair } from '@solana/
import base58 from 'bs58';
// Check that a transaction is basically valid, sign it, and serialize it, verifying the signatures
// This function doesn't check if payer fee was transferred (instead, use validateTransfer) or
// instruction signatures do not include fee payer as a writable account (instead, use validateInstructions).
export async function validateTransaction(
connection: Connection,
transaction: Transaction,
@ -33,14 +35,6 @@ export async function validateTransaction(
if (!signature.signature) throw new Error('missing signature');
}
// 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(feePayer.publicKey))
throw new Error('invalid account');
}
}
// Add the fee payer signature
transaction.partialSign(feePayer);

View File

@ -0,0 +1,154 @@
import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import base58 from 'bs58';
// @ts-ignore (TS7016) There is no type definition for this at DefinitelyTyped.
import MemoryStore from 'cache-manager/lib/stores/memory';
import cacheManager from 'cache-manager';
import { Keypair, PublicKey, Connection, Transaction, sendAndConfirmRawTransaction } from '@solana/web3.js';
import {
createMint,
getAssociatedTokenAddress,
getOrCreateAssociatedTokenAccount,
Account,
mintTo,
createTransferInstruction,
createAccount,
getAccount,
createAssociatedTokenAccountInstruction,
} from '@solana/spl-token';
import { createAccountIfTokenFeePaid } from '../../src';
import { AllowedToken } from '../../src/core';
import { airdropLamports } from '../common';
use(chaiAsPromised);
if (process.env.TEST_LIVE) {
describe('createAccountIfTokenFeePaid action', async () => {
let connection: Connection;
let feePayerKeypair: Keypair; // Payer for submitted transactions
let tokenKeypair: Keypair; // Token owner
let mint: PublicKey;
let feePayerTokenAccount: Account; // Account for fees in tokens
let baseAllowedTokens: AllowedToken[];
let cache: cacheManager.Cache;
before(async () => {
cache = cacheManager.caching({ store: MemoryStore, max: 1000, ttl: 120 });
connection = new Connection('http://localhost:8899/', 'confirmed');
feePayerKeypair = Keypair.generate();
tokenKeypair = Keypair.generate();
await airdropLamports(connection, tokenKeypair.publicKey, feePayerKeypair.publicKey);
mint = await createMint(connection, tokenKeypair, tokenKeypair.publicKey, null, 9);
feePayerTokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
feePayerKeypair,
mint,
feePayerKeypair.publicKey
);
baseAllowedTokens = [
{
mint: mint,
account: feePayerTokenAccount.address,
decimals: 9,
fee: BigInt(100),
},
];
});
let sourceOwner: Keypair;
let sourceAccount: PublicKey;
let recentBlockhash = '';
beforeEach(async () => {
// We shouldn't airdrop any SOL to this keypair
sourceOwner = Keypair.generate();
sourceAccount = await createAccount(connection, feePayerKeypair, mint, sourceOwner.publicKey);
await mintTo(connection, tokenKeypair, mint, sourceAccount, tokenKeypair.publicKey, 5000);
recentBlockhash = (await connection.getRecentBlockhash()).blockhash;
});
it('signs a transaction with initialization fees and token transfer to a previously not used associated token account', async () => {
const targetOwner = Keypair.generate();
const targetAccountAddress = await getAssociatedTokenAddress(mint, targetOwner.publicKey, false);
// We first have to create an associated account for target owner
const accountTransaction = new Transaction();
accountTransaction.add(
createTransferInstruction(sourceAccount, feePayerTokenAccount.address, sourceOwner.publicKey, 100)
);
accountTransaction.add(
createAssociatedTokenAccountInstruction(
// We are using Octane's public key, since the initialization fees have to be paid in SOL
// and our hypothetical user doesn't have any SOL.
feePayerKeypair.publicKey,
targetAccountAddress,
targetOwner.publicKey,
mint
)
);
accountTransaction.feePayer = feePayerKeypair.publicKey;
accountTransaction.recentBlockhash = recentBlockhash;
accountTransaction.partialSign(sourceOwner);
await expect(getAccount(connection, targetAccountAddress, 'confirmed')).to.be.rejected;
const { signature } = await createAccountIfTokenFeePaid(
connection,
accountTransaction,
feePayerKeypair,
2,
5000,
baseAllowedTokens,
cache
);
expect(signature).to.not.be.empty;
accountTransaction.addSignature(feePayerKeypair.publicKey, base58.decode(signature));
await sendAndConfirmRawTransaction(connection, accountTransaction.serialize(), { commitment: 'confirmed' });
expect((await connection.getSignatureStatus(signature)).value!.confirmationStatus).to.be.equals(
'confirmed'
);
expect((await getAccount(connection, targetAccountAddress, 'confirmed')).isInitialized).to.be.true;
expect((await getAccount(connection, feePayerTokenAccount.address, 'confirmed')).amount).to.equal(
BigInt(100)
);
});
it('rejects a transaction with previously created account', async () => {
const targetOwner = Keypair.generate();
const targetAccount = await createAccount(connection, feePayerKeypair, mint, targetOwner.publicKey);
// We first have to create an associated account for target owner
const accountTransaction = new Transaction();
accountTransaction.add(
createTransferInstruction(sourceAccount, feePayerTokenAccount.address, sourceOwner.publicKey, 100)
);
accountTransaction.add(
createAssociatedTokenAccountInstruction(
// We are using Octane's public key, since the initialization fees have to be paid in SOL
// and our hypothetical user doesn't have any SOL.
feePayerKeypair.publicKey,
targetAccount,
targetOwner.publicKey,
mint
)
);
accountTransaction.feePayer = feePayerKeypair.publicKey;
accountTransaction.recentBlockhash = recentBlockhash;
accountTransaction.partialSign(sourceOwner);
await expect(
createAccountIfTokenFeePaid(
connection,
accountTransaction,
feePayerKeypair,
2,
5000,
baseAllowedTokens,
cache
)
).to.be.rejectedWith('account already exists');
});
// todo: cover more errors while signing memory transaction.
});
}

View File

@ -0,0 +1,64 @@
import { PublicKey, sendAndConfirmRawTransaction, Transaction } from '@solana/web3.js';
import type { NextApiRequest, NextApiResponse } from 'next';
import base58 from 'bs58';
import { ENV_SECRET_KEYPAIR, cors, rateLimit, connection, cache } from '../../src';
import config from '../../../../config.json';
import { createAccountIfTokenFeePaid } from '@solana/octane-core';
// Endpoint to create associated token account with transaction fees and account initialization fees paid by SPL tokens
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 createAccountIfTokenFeePaid(
connection,
transaction,
ENV_SECRET_KEYPAIR,
config.maxSignatures,
config.lamportsPerSignature,
config.endpoints.createAssociatedTokenAccount.tokens.map((token) => ({
mint: new PublicKey(token.mint),
account: new PublicKey(token.account),
decimals: token.decimals,
fee: BigInt(token.fee),
})),
cache
);
transaction.addSignature(
ENV_SECRET_KEYPAIR.publicKey,
Buffer.from(base58.decode(signature))
);
await sendAndConfirmRawTransaction(
connection,
transaction.serialize(),
{commitment: 'confirmed'}
);
// Respond with the confirmed transaction signature
response.status(200).send({ status: 'ok', signature });
} catch (error) {
let message = '';
if (error instanceof Error) {
message = error.message;
}
response.status(400).send({ status: 'error', message });
}
}