액세스 트랜잭션 수수료 구조

액세스 프로토콜
Access Protocol
Published in
13 min readNov 16, 2023

Wrote by Vlad

블라드(Vlad)는 액세스 프로토콜의 풀 스택 소프트웨어 엔지니어로, 솔라나 프로그램 개발과 백엔드 및 UI를 담당하고 있습니다. 블라드는 전통 핀테크 회사에서 대규모 시스템을 구축하였으며, 이후 웹3로 뛰어들었습니다. 엑스(트위터) 또는 이메일을 통해 블라드에게 연락하실 수 있습니다.

새로운 사용자에게 웹3는 어렵고 두려울 수 있습니다. 애플리케이션을 사용하기 위해서는 때때로 다른 토큰이 필요하거나 수수료를 내야 하기 때문입니다. 따라서 소비자를 위한 웹3 앱은 가능한 한 많은 백엔드 인프라를 숨겨 사용자 경험을 원활하고 단순하게 만들기 위해 노력해야 합니다.

솔라나 트랜잭션 수수료 구조

솔라나 블록체인에 트랜잭션을 전송할 때마다 누군가 트랜잭션 처리를 위한 수수료를 지불해야 합니다. 이 수수료는 부분적으로 소각되고 일부는 네트워크 운영에 대한 보상으로 검증자(밸리데이터)에게 전송됩니다. 이러한 수수료는 매우 적지만, 어쨌든 사용자가 블록체인과 상호작용하려면 지갑에 $SOL이 있어야 합니다. 또한 SPL 토큰으로 구동되는 앱은 신규 사용자가 트랜잭션을 처리하기 위해 $SOL 외에도 앱과 상호작용하기 위한 프로토콜별 SPL 토큰을 획득하는 추가 단계를 거쳐야 하므로 온보딩을 복잡합니다. 이러한 개념은 암호화폐를 오랫동안 사용해온 사용자에게는 당연한 것일 수 있지만, 이러한 제품을 처음 접하는 사용자는 쉽게 겁을 먹고 해당 앱은 물론 암호화폐 앱 자체를 사용하지 않으려 할 수 있습니다.

이 글에서 설명하는 접근 방식을 채택하기 전에는 액세스 허브 앱과 상호작용하는 모든 사람이 이런 문제를 겪었습니다.

저희가 이 문제에 대해 내린 해결책은 매우 간단합니다. 앱이 사용자에게 $SOL 수수료를 지불하는 것입니다. 솔라나에서는 아래 문서에 명시된 대로 이러한 방법을 지원하고 있습니다.

트랜잭션에는 트랜잭션에 서명하고 쓰기 가능한 계정이 하나 이상 있어야 합니다. 쓰기 가능한 서명자 계정은 트랜잭션 계정 목록에서 가장 먼저 직렬화되며, 이 계정 중 첫 번째 계정은 항상 “수수료 지불자”로 사용됩니다.

여기서 생략된 한 가지 중요한 사실은 이 계정이 트랜잭션에 포함된 명령어에 따라 사용될 필요가 없다는 것입니다.

트랜잭션 수수료 보조금 지원

따라서 저희가 해야 할 일은 수수료 지불 전용 지갑의 키를 주입하고 이 지갑을 사용하여 트랜잭션에 서명하는 것뿐입니다.

트랜잭션을 수정하여 수동으로 이 작업을 수행할 수도 있지만, 다행히도 @solana/web3.js 자바스크립트 라이브러리가 이 작업을 수행할 수 있는 메커니즘을 제공합니다. 해당 ‘트랜잭션’ 객체에서 ‘수수료 지불자’ 필드를 설정하기만 하면 됩니다. 다음은 코드를 통해 이를 구현하는 방법의 예시입니다:

(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);
})()

이 예시에서는 레거시 트랜잭션 형식을 사용하지만, 버전화된 트랜잭션이 필요한 경우에도 접근 방식은 거의 동일합니다.

하지만 아직 해결되지 않은 몇 가지 문제가 있습니다:

1. 두 트랜잭션이 모두 동일한 코드 블록에서 서명되고 있습니다. 클라이언트의 브라우저에서 이런 일이 발생하면 수수료 지불자 지갑의 개인키를 검색하여 자금을 훔칠 수 있습니다.

2. 계정 생성에 대한 비용을 지불하는 경우 또 다른 익스플로잇의 가능성이 생깁니다. 계정 소유자는 계정을 폐쇄하고 수수료 지불자가 지불한 이 계정의 임대료를 청구할 수 있습니다. 이 과정을 무기한 반복하여 수수료 지불자의 지갑을 고갈시킬 수 있습니다.

3. 저희가 모든 수수료를 지불하기 때문에 확장성이 떨어지며, 특히 아래에 언급된 계정 생성과 같이 저희가 지원하는 절차가 더 비싼 경우 수수료 지불자 지갑을 충전해야 합니다.

문제 해결

액세스 프로토콜에서 시도한 해결책과 가장 효과적인 방법을 설명하겠습니다. 애플리케이션 프런트엔드에서 몇 개의 램포트를 절약하기 위해 할 수 있는 간단한 방법은 단 하나뿐입니다. 사용자가 스스로 수수료를 지불할 수 있는 적정량의 SOL을 지갑에 가지고 있는지 확인하고 그렇지 않은 경우에만 보조금을 지급하면 됩니다:

 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);

하지만 저희 경험상 사용자들은 금방 이 사실을 깨닫고 $SOL을 다른 지갑으로 옮깁니다. 따라서 서명을 처리하고 거래 적격성을 확인하는 백엔드 서비스를 사용하는 것이 필수적입니다. 백엔드에서 트랜잭션을 구성한 다음 사용자의 브라우저에서 서명하고 전송할지(그림 A) 아니면 그 반대의 경우(그림 B)를 선호할지 직접 결정할 수 있습니다.

수수료 결제자 통합 옵션

옵션 A와 B의 주요 차이점은 다음과 같습니다:

1. A에서는 백엔드에서 트랜잭션을 생성하므로 클라이언트 측에서 추가 확인을 구현할 필요가 없습니다. 하지만 트랜잭션 생성 및 클라이언트에서 매개변수 전달과 관련하여 더 많은 백엔드 로직이 필요합니다.

2. B에서 백엔드 서버는 클라이언트로부터 모든 트랜잭션을 수락하므로 이 트랜잭션이 관련성이 있는지 확인한 다음 서명해야 합니다.

3. 접근 방식 B의 한 가지 분명한 장점은 솔라나 RPC 노드의 주소를 노출하지 않기 때문에 클라이언트가 주소를 추출하여 오용할 수 없다는 것입니다.

저희는 접근 방식 B를 사용하기로 했으며, 따라서 아래에서는 이 접근 방식의 문제를 해결하는 방법을 설명합니다.

이미 언급했듯이 백엔드 서비스를 사용하면 수수료를 지불하는 트랜잭션에 추가 확인을 부과하는 데 도움이 됩니다. 앞서 언급한 문제를 완화하기 위해 다음과 같은 방법을 시도했습니다:

1. 특정 온체인 프로그램에 대한 호출만 허용하는 방법: 이는 지불자가 앱과 관련이 없는 트랜잭션에 서명하는 것을 허용하지 않기 때문에 꽤 잘 작동합니다.

2. 계정 생성에 대한 비용을 지불하지 않는 방법: 이 방법은 앱이 온체인에서 계정을 생성할 필요가 없는 경우 매우 효율적입니다. 저희 상황에는 맞지 않습니다.

3. 사용자당 지불하는 수수료 금액을 제한하는 방법: 이는 계정 생성에 대한 비용을 지불하는 경우 효율적이지 않습니다. 많은 지갑을 생성하고 수수료 지불자 지갑 자금을 고갈시키는 것은 매우 쉽습니다.

4. $ACS 토큰으로 $SOL 수수료를 보상하는 방법: 저희는 액세스 앱 사용자가 $ACS 토큰을 소유하고 있다고 확신했기 때문에 트랜잭션에 계정을 생성하는 명령어를 하나 더 추가하기로 했습니다. 이 명령어는 $ACS 토큰 전송으로, 소량의 사용자 토큰을 수수료 지불자 지갑으로 전송하여 사용한 $SOL에 대한 보상을 받는 방식입니다. 이 접근 방식은 앱의 기본 토큰인 $ACS를 통해 사용자 트랜잭션에 대해 지불한 $SOL 수수료를 상환하기 때문에 사용자와 저희 모두에게 확장 가능하고 원활하다는 것이 입증되었습니다. 자동화해야 했던 유일한 작업은 주피터 어그리게이터를 사용하여 $ACS를 $SOL로 교환하는 것이었지만, 이에 대해서는 다음 기회에 자세히 설명하겠습니다.

다음은 저희에게 가장 효과적이었던 백엔드 코드의 구조로, 자바스크립트로 재작성된 것입니다:

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);

이 코드는 백엔드 서버의 일부로 사용할 수 있으며 특정 요구 사항에 맞게 확장할 수 있습니다. 클라이언트의 SPL 전송 명령은 완성도를 높이기 위해 다음과 같이 구성됩니다:

const splTransferIx = spl_token.createTransferInstruction(
getAssociatedTokenAddressSync(mint, from.publicKey),
payerATA,
from.publicKey,
30e6,
)

부분적으로 서명된 트랜잭션을 백엔드 서버로 전송하기 전에 다음과 같이 직렬화합니다:

const serializedTx = transaction.serialize({requireAllSignatures: false}).toString('hex');

결론

지금까지 살펴본 것처럼 솔라나 블록체인에서 사용자에 대한 수수료 지불을 구현하는 것은 복잡하지 않습니다. 해결해야 할 유일한 과제는 숙련된 사용자가 이를 악용할 유인을 갖지 않도록 지불된 수수료에 대해 보상하는 것입니다.

저는 솔라나에서 사용자 대면 애플리케이션을 개발하는 사람이라면 누구나 $SOL 수수료에 대한 보조금을 고려해야 한다고 생각합니다. 이는 새로운 웹3 사용자의 온보딩 프로세스를 크게 간소화하고 솔라나 블록체인의 대량 채택을 위한 올바른 방향으로 나아갈 수 있게 해줍니다. 이 작업을 진행하는 데 어려움을 겪고 계신다면 언제든지 문의해 주세요.

--

--

액세스 프로토콜
Access Protocol

전세계 모든 디지털 콘텐츠 크리에이터를 위한 새로운 수익 창출 레이어 https://accessprotocol.co