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:
parent
6d45f42dfd
commit
2dbbe9c0b2
10
config.json
10
config.json
|
@ -14,6 +14,16 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"createAssociatedTokenAccount": {
|
||||
"tokens": [
|
||||
{
|
||||
"mint": "__PLACEHOLDER__",
|
||||
"account": "__PLACEHOLDER__",
|
||||
"decimals": 9,
|
||||
"fee": 10000000
|
||||
}
|
||||
]
|
||||
},
|
||||
"whirlpoolsSwap": {
|
||||
"tokens": [
|
||||
{
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export * from './buildWhirlpoolsSwapToSOL';
|
||||
export * from './signGeneratedTransaction';
|
||||
export * from './signIfTokenFeePaid';
|
||||
export * from './createAccountIfTokenFeePaid';
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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.
|
||||
});
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue