Cover image
NOV 6, 2023

Who's gonna pay for it?

by Vlad, Access Protocol

Deep dive on how the Access Protocol simplified fee payment for the users of their application and you can as well
Vlad is a Full-Stack Software Engineer at Access Protocol, working on Solana Program development as well as the Backend and User-Facing applications. Vlad has moved to the exciting world of crypto after building big systems in traditional FinTech companies. You can reach out to Vlad on X (@mmatdev) or via e-mail at vladislav@accessprotocol.co.
Web3 might be scary for new users - you are interacting with applications with user flows that sometimes require different tokens to carry out user actions and pay for the network fees. Therefore, consumer crypto protocols should strive to make the user experience as smooth and simple by hiding as much of the backend infrastructure as possible.

Default behavior of the transaction fee payment on Solana

Every time you send a transaction to the Solana blockchain, someone has to pay the fee for this transaction to be processed. These fees are partially burned and partially sent to validators as a reward for keeping the network running. Even though these fees are very small, the user has to have some SOL in their wallet to be able to interact with the blockchain. This complicates onboarding of new users to applications powered by SPL tokens, requiring them to take the extra step of acquiring the protocol specific SPL token in order to interact with the application in addition to SOL to process the transaction. This concept may be a no-brainer for long time crypto users, but those new to these products can and will be easily scared off by this and leave the application, and possibly crypto in general, for good.
This used to be the case of anyone interacting with the Access Hub application before we adopted the approach described in this article. 
The solution to this issue is quite straightforward - the application needs to pay the SOL fees for the user. The good thing is that Solana allows us to do this. As the documentation states:

Transactions are required to have at least one account which has signed the transaction and is writable. Writable signer accounts are serialized first in the list of transaction accounts and the first of these accounts is always used as the "fee-payer".

One important fact that is omitted here is that this account doesn't have to be used by any of the instructions included in the transaction.

Subsidizing the transaction fee payment

So the only thing that we have to do is to inject the key of a dedicated fee-payer wallet and sign the transaction using this wallet.
We could do this manually by modifying the transaction, but luckily, the @solana/web3.js JavaScript library provides a mechanism for doing this. We just need to set the `feePayer` field in the corresponding `Transaction` object. Here is an example how this could be implemented in the code:
(async () => { const instructions = <DESIRED INSTRUCTIONS> const transaction = new web3.Transaction().add(...instructions); transaction.feePayer = payer.publicKey; transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; transaction.partialSign(payer); transaction.partialSign(from); const signature = await connection.sendRawTransaction( transaction.serialize(), ) console.log('SIGNATURE', signature); })()
Note that this example uses the Legacy Transaction format, but if you need Versioned Transactions, the approach is practically identical.
However, there are still a few unresolved issues:
1. Both of these transaction are being signed in the same code block. If this happens in the client's browser, it would be simple to retrieve the private key of our fee-payer wallet and steal the funds.
2. If we are paying for an account creation it opens up another possibility for an exploit. The account owner can close the account and claim the Rent in this account that the fee-payer has paid. This can be indefinitely repeated to drain the fee-payer wallet.
3. It does not scale well as we pay for all the fees and will need to top up our fee-payer wallet, especially if the instructions that we are subsidizing are more expensive ones - for example account creations mentioned bellow.

Solving the issues

Let me outline the solutions that we have tried at Access Protocol and describe what worked for us the best.There is only one simple thing that you can do on the frontend of your application to save a few lamports. You can check if the user has a reasonable amount of SOL in their wallet to pay for the fees on their own and only subsidize only if this is not the case:
const balanceLimit = web3.LAMPORTS_PER_SOL / 100; const transaction = new web3.Transaction().add(...instructions); let balance = await connection.getBalance(from.publicKey); transaction.feePayer = balance > balanceLimit ? from.publicKey : payer.publicKey; transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; if (balance <= balanceLimit / 10) { // We sign the transaction transaction.partialSign(payer); } // The client signs the transaction transaction.partialSign(from);
However, from our experience, users quickly realize this and move their SOL into an another wallet.A thing that you cannot really avoid is having a backend service that will take care of the signing and check the transaction eligibility. You can decide for yourself if you prefer to construct the transactions on your backend and then sign and send them from the user's browser (figure A) or vice versa (figure B)
Fee-payer integration options
Fee-payer integration options
The key differences between option A and B are:
1. In A we create the transaction on the backend so we don't have to implement any additional checks on the client's side. However, it needs more backend logic around the transaction creation and passing the parameters from the client.
2. In B the backend server accepts any transaction from the client so it needs to check if this transaction is relevant and only then sign it.
3. One clear advantage of the approach B is that we do not expose the address of our Solana RPC Node so it cannot be extracted and misused by the client.
We have decided to use the approach B, therefore, the following text explains tackling the issues in this approach.
As already mentioned, having the backend service helps you with imposing additional checks onto the transactions that you pay the fees for. We tried the following to mitigate the previously mentioned issues:
1. Allowing only calls to specific on-chain programs: This works pretty well as it disallows people to let your payer sign transactions not relevant to your application.
2. Not paying for the account creations: This approach is pretty efficient if your application doesn't need to create any accounts on-chain. This wasn't the case for us.
3. Limiting the amount of fees paid per user: This is not very efficient if you are paying for the account creation. It is very easy to create a lot of wallets and start draining the fee-payer wallet funds.
4. Compensating for the SOL fees using the ACS token: As we are sure that the user of our application owns ACS tokens, we started adding one more instruction to the transactions, which are creating an account. This instruction is an ACS token transfer to send a small amount of user's tokens to our fee-payer wallet, thereby compensating us for the spent SOL. This approach has proven to be scalable and smooth for both the users and us as it reimburses us for the SOL fees paid for the user transactions using the ACS token native to our application. The only thing that we had to automate was swapping the ACS to SOL using Jupiter aggregator, but more on this next time.
Here is a skeleton of the backend code, that worked for us the best, rewritten in JavaScript:
const mint = new web3.PublicKey('5MAYDfq5yxtudAhtfyuMBuHZjgAbaS9tbEyEQYAhDS5y'); const payerATA = spl_token.getAssociatedTokenAddressSync(mint, payer.publicKey); const accountCreationFeeInACS = 30e6; const tx = web3.Transaction.from(Buffer.from(feTx, 'hex')); let totalFee = 0; let neededFee = 0; // We check that the transaction contains only allowed instructions for (const ix of tx.instructions) { switch (ix.programId.toBase58()) { // We allow instructions calling our program case yourProgramID: // We get the instruction ID from the instruction data const ixID = ix.data[0] // If we know that this instruction creates an account, we add the fee to the amount needed to be paid by the user if (ixID === 3 || ixID === 12 || ixID === 23) neededFee += accountCreationFeeInACS; break; // We allow SPL token transfer instructions for the SOL fee reimbursement in SPL token case spl_token.TOKEN_PROGRAM_ID.toBase58(): if (ix.data.length !== 9 || ix.data[0] !== 3) throw new Error('Only token transfer instructions are supported'); const destinationTokenAccount = ix.keys[1].pubkey; if (!destinationTokenAccount.equals(payerATA)) throw new Error('Only transfers to the fee-payer are supported'); // We calculate how much ACS will be paid by the user to check if it is enough to pay for account creation const amount = ix.data.readUInt32LE(1); totalFee += amount; break; // We allow ComputeBudgetProgram instructions as they are added to every transaction when signing with the Phantom wallet and are harmless case web3.ComputeBudgetProgram.programId.toBase58(): break; default: throw new Error('Unsupported instruction'); } } // We check if the user reimburses our fee-payer for the account creations if (totalFee < neededFee) throw new Error('Not enough ACS to pay for account creations'); // If everything is fine, we sign and send the transaction tx.partialSign(from); const signature = await connection.sendRawTransaction(tx.serialize()); console.log('SIGNATURE', signature);
This code can be used as a part of your Backend server and can be extended to fit your specific needs. Just for completeness, the SPL transfer instruction on client is constructed as follows:
const splTransferIx = spl_token.createTransferInstruction( getAssociatedTokenAddressSync(mint, from.publicKey), payerATA, from.publicKey, 30e6, )
and we are serializing the partially signed transaction before sending it to Backend server as follows:
const serializedTx = transaction.serialize({requireAllSignatures: false}).toString('hex');

Conclusion

As you have seen, implementation of the fee payment for the user on Solana blockchain is not complicated. The only challenge that you have to solve is compensation for the paid fees so that the experienced users do not have any incentive to abuse it.
I believe that anyone developing a user facing application on Solana should consider subsidizing the SOL fees. It greatly simplifies the onboarding process for new Web3 users and moves us in the right direction to the mass adoption of the Solana Blockchain. If you are struggling to make this work, feel free to reach out.
Comments
To comment, please sign in.
Article has no comments yet.