트랜잭션 빌드 가이드
서명 대상이 되는 원본 트랜잭션(serializedUnsignedTransaction)을 생성하는 방법을 설명합니다.
여기서 생성한 값은 옥텟 API의 출금 신청 또는 트랜잭션 서명 신청 API의 입력값으로 사용됩니다.
비트코인
비트코인은 UTXO 기반이므로 보내는 주소가 보유한 UTXO 목록이 필요합니다.
UTXO는 옥텟 API의 UTXO 목록 조회 API로 조회할 수 있습니다.
utxos: 보내는 주소가 보유한 UTXO 목록. UTXO 목록 조회 API 응답의txid,outputIndex(→vout),script를 그대로 사용하고,amount는 BTC 단위이므로 satoshi로 변환하여value에 넣습니다.feeRate: 수수료율 (sat/vByte). 옥텟 API의 지갑에 등록된 자산의 수수료 조회 API로 조회할 수 있으며, 응답의withdrawalFee.gasPrice값이 비트코인의 수수료율(sat/vByte)에 해당합니다.coinselect가 수수료를 반영해 사용할 UTXO를 선택하고 잔돈(change) output을 계산합니다.- 옥텟의 비트코인 기본 주소 타입은 P2WPKH(SegWit,
bc1으로 시작)이므로 예시도 P2WPKH 기준이며,witnessUtxo로 input을 구성합니다. 빌드 결과물은 PSBT hex입니다. - 비트코인은 트랜잭션 서명 신청 API를 지원하지 않으므로, 여기서 만든 PSBT는 출금 신청 전용입니다.
// bitcoinjs-lib: 6.1.7
// coinselect: 3.1.13
import * as bitcoin from 'bitcoinjs-lib';
const coinSelect = require('coinselect');
/*
NETWORK
mainnet: bitcoin.networks.bitcoin
testnet: bitcoin.networks.testnet
*/
const network = bitcoin.networks.bitcoin;
const fromAddress = 'bc1qm3p027vycz73t99ewclkjz53c4qkyswmcnwve9'; // 보내는 주소 (잔돈 수령 주소)
const toAddress = 'bc1ql6tme8r3wk88nuldy2eguaz7rgtg0efhl7h9v7'; // 받는 주소
const amount = 6000000; // 보낼 금액 (satoshi)
const feeRate = 10; // 수수료율 (sat/vByte, 수수료 조회 API 응답의 withdrawalFee.gasPrice)
// 보내는 주소가 보유한 UTXO 목록 (UTXO 목록 조회 API로 조회)
const utxos = [
{
txid: 'ff219ded839a14010222985c4f314f123a50b5d6984c8c8d41f1ad037ef8e9a9', // UTXO 트랜잭션 해시
vout: 1, // UTXO output 인덱스 (조회 응답의 outputIndex)
value: 5000000, // UTXO 금액 (satoshi)
script: '0014dc42f57984c0bd1594b9763f690a91c5416241db', // UTXO의 scriptPubKey (hex)
},
{
txid: 'b4f5e8a209cc41d6d24f7f8ccde3f7a5be9d9ad3ad3f7f6a30cf50f4406eab12',
vout: 0,
value: 3000000,
script: '0014dc42f57984c0bd1594b9763f690a91c5416241db',
},
];
// 수수료를 반영해 사용할 input과 output(잔돈 포함)을 계산
const { inputs, outputs } = coinSelect(utxos, [{ address: toAddress, value: amount }], feeRate);
if (!inputs || !outputs) {
throw new Error('Insufficient funds');
}
const psbt = new bitcoin.Psbt({ network });
for (const input of inputs) {
psbt.addInput({
hash: Buffer.from(input.txid, 'hex').reverse(), // txid는 little-endian으로 변환
index: input.vout,
witnessUtxo: {
script: Buffer.from(input.script, 'hex'),
value: input.value,
},
});
}
for (const output of outputs) {
// address가 없는 output은 잔돈(change)이므로 보내는 주소로 되돌려 받는다
psbt.addOutput({
address: output.address ?? fromAddress,
value: output.value,
});
}
const serializedUnsignedTransaction = psbt.toHex();
// 70736274ff01009a0200000002a9e9f87e03adf1418d8c4c98d6b5503a124f314f5c98220201149a83ed9d21ff0100000000ffffffff12ab6e40f450cf306a7f3fadd39a9dbea5f7e3cd8c7f4fd2d641cc09a2e8f5b40000000000ffffffff02808d5b0000000000160014fe97bc9c71758e79f3ed22b28e745e1a1687e537d07a1e0000000000160014dc42f57984c0bd1594b9763f690a91c5416241db000000000001011f404b4c0000000000160014dc42f57984c0bd1594b9763f690a91c5416241db0001011fc0c62d0000000000160014dc42f57984c0bd1594b9763f690a91c5416241db000000이더리움 계열 (EVM)
이더리움, 아비트럼, 폴리곤, 아발란체, 바이낸스스마트체인은 모두 동일한 방식으로 빌드하며, 플랫폼별 차이는 CHAIN_ID와 지원 트랜잭션 타입뿐입니다.
| 플랫폼 | CHAIN_ID (mainnet) | CHAIN_ID (testnet) | 지원 트랜잭션 타입 |
|---|---|---|---|
| 이더리움 | 1 | 11155111 (Sepolia) | EIP-1559, Legacy |
| 아비트럼 | 42161 | 421614 (Sepolia) | EIP-1559, Legacy |
| 폴리곤 | 137 | 80002 (Amoy) | EIP-1559, Legacy |
| 아발란체 | 43114 | 43113 (Fuji) | EIP-1559, Legacy |
| 바이낸스스마트체인 | 56 | 97 | Legacy만 (EIP-1559 미지원) |
nonce: 보내는 주소의 트랜잭션 카운트 (eth_getTransactionCount로 조회)maxFeePerGas/maxPriorityFeePerGas: EIP-1559 가스 가격 (gwei). Legacy 트랜잭션은gasPrice하나만 사용합니다.- 네이티브 코인 전송의
gasLimit은 21000 고정, 토큰 전송은 토큰별로 다르므로 여유 있게 설정하거나eth_estimateGas로 추정합니다. - 출금 신청에 사용하는 트랜잭션은 네이티브 코인 전송 또는 ERC-20
transfer호출만 허용됩니다(그 외 calldata는 거절). 트랜잭션 서명 신청은 임의 calldata를 허용합니다.
EIP-1559 — 네이티브 코인 전송
아래는 이더리움 기준 예시이며, 아비트럼·폴리곤·아발란체는 위 표의 CHAIN_ID로만 바꾸면 됩니다.
// @ethereumjs/tx: 5.4.0
// @ethereumjs/common: 4.4.0
// web3: 4.16.0
import { FeeMarketEIP1559Transaction } from '@ethereumjs/tx';
import { Common } from '@ethereumjs/common';
import { Web3 } from 'web3';
const web3 = new Web3();
/*
CHAIN_ID
mainnet: 1
testnet(sepolia): 11155111
*/
const CHAIN_ID = 1;
const HARDFORK = 'london';
const toAddress = '0x4c81fec94ab7a764667ad35b55190cbeb4cd4a34'; // 받는 주소
const amount = '0.02'; // 보낼 금액 (ETH)
const nonce = 0; // 보내는 주소의 트랜잭션 카운트 (eth_getTransactionCount)
const maxFeePerGasGwei = '41'; // 최대 가스 가격 (gwei)
const maxPriorityFeePerGasGwei = '1'; // 우선순위 수수료 (gwei)
const txData: any = {
type: '0x02',
nonce: web3.utils.toHex(nonce),
to: toAddress,
value: web3.utils.toHex(BigInt(web3.utils.toWei(amount, 'ether'))),
gasLimit: web3.utils.toHex(21000),
maxFeePerGas: web3.utils.toHex(BigInt(web3.utils.toWei(maxFeePerGasGwei, 'gwei'))),
maxPriorityFeePerGas: web3.utils.toHex(BigInt(web3.utils.toWei(maxPriorityFeePerGasGwei, 'gwei'))),
};
const common = Common.custom({ chainId: CHAIN_ID }, { hardfork: HARDFORK });
const tx = FeeMarketEIP1559Transaction.fromTxData(txData, { common });
const serializedUnsignedTransaction = `0x${Buffer.from(tx.serialize()).toString('hex')}`;
// 0x02f20180843b9aca0085098bca5a00825208944c81fec94ab7a764667ad35b55190cbeb4cd4a3487470de4df82000080c0808080위 출력값은 트랜잭션 서명 가이드의 이더리움 예시 입력값과 동일합니다.
EIP-1559 — ERC-20 토큰 전송
컨트랙트의 transfer(to, value) 호출을 ABI 인코딩하여 data에 넣고, to는 토큰 컨트랙트 주소로 설정합니다.
value(전송 금액)는 토큰의 decimals가 적용된 최소 단위 정수로 전달합니다.
// @ethereumjs/tx: 5.4.0
// @ethereumjs/common: 4.4.0
// web3: 4.16.0
import { FeeMarketEIP1559Transaction } from '@ethereumjs/tx';
import { Common } from '@ethereumjs/common';
import { Web3, Contract } from 'web3';
const web3 = new Web3();
/*
CHAIN_ID
mainnet: 1
testnet(sepolia): 11155111
*/
const CHAIN_ID = 1;
const HARDFORK = 'london';
const Erc20TransferAbi: any = [
{
inputs: [
{ name: '_to', type: 'address' },
{ name: '_value', type: 'uint256' },
],
name: 'transfer',
outputs: [{ name: '', type: 'bool' }],
stateMutability: 'nonpayable',
type: 'function',
},
];
const contractAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7'; // 토큰 컨트랙트 주소 (예시: USDT)
const toAddress = '0x4c81fec94ab7a764667ad35b55190cbeb4cd4a34'; // 받는 주소
const amount = '12500000'; // 보낼 금액 - 토큰 decimals가 적용된 최소 단위 (12.5 USDT, decimals: 6)
const nonce = 0; // 보내는 주소의 트랜잭션 카운트 (eth_getTransactionCount)
const maxFeePerGasGwei = '41'; // 최대 가스 가격 (gwei)
const maxPriorityFeePerGasGwei = '1'; // 우선순위 수수료 (gwei)
const contract = new Contract(Erc20TransferAbi, contractAddress);
const data = contract.methods.transfer(toAddress, amount).encodeABI();
const txData: any = {
type: '0x02',
nonce: web3.utils.toHex(nonce),
to: contractAddress,
value: '0x0',
data,
gasLimit: web3.utils.toHex(100000),
maxFeePerGas: web3.utils.toHex(BigInt(web3.utils.toWei(maxFeePerGasGwei, 'gwei'))),
maxPriorityFeePerGas: web3.utils.toHex(BigInt(web3.utils.toWei(maxPriorityFeePerGasGwei, 'gwei'))),
};
const common = Common.custom({ chainId: CHAIN_ID }, { hardfork: HARDFORK });
const tx = FeeMarketEIP1559Transaction.fromTxData(txData, { common });
const serializedUnsignedTransaction = `0x${Buffer.from(tx.serialize()).toString('hex')}`;
// 0x02f8710180843b9aca0085098bca5a00830186a094dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb0000000000000000000000004c81fec94ab7a764667ad35b55190cbeb4cd4a340000000000000000000000000000000000000000000000000000000000bebc20c0808080Legacy — 바이낸스스마트체인
바이낸스스마트체인은 EIP-1559를 지원하지 않으므로 반드시 Legacy 트랜잭션(gasPrice)으로 빌드해야 합니다.
BEP-20 토큰 전송은 위 ERC-20 예시와 동일한 방식으로 data를 인코딩하고, 아래와 같이 LegacyTransaction + gasPrice로 빌드하면 됩니다.
이더리움·아비트럼·폴리곤·아발란체에서도 같은 방식의 Legacy 트랜잭션을 사용할 수 있습니다.
// @ethereumjs/tx: 5.4.0
// @ethereumjs/common: 4.4.0
// web3: 4.16.0
import { LegacyTransaction } from '@ethereumjs/tx';
import { Common } from '@ethereumjs/common';
import { Web3 } from 'web3';
const web3 = new Web3();
/*
CHAIN_ID
mainnet: 56
testnet: 97
*/
const CHAIN_ID = 56;
const toAddress = '0x4c81fec94ab7a764667ad35b55190cbeb4cd4a34'; // 받는 주소
const amount = '0.02'; // 보낼 금액 (BNB)
const nonce = 0; // 보내는 주소의 트랜잭션 카운트 (eth_getTransactionCount)
const gasPriceGwei = '41'; // 가스 가격 (gwei)
const txData: any = {
nonce: web3.utils.toHex(nonce),
to: toAddress,
value: web3.utils.toHex(BigInt(web3.utils.toWei(amount, 'ether'))),
gasLimit: web3.utils.toHex(21000),
gasPrice: web3.utils.toHex(BigInt(web3.utils.toWei(gasPriceGwei, 'gwei'))),
};
const common = Common.custom({ chainId: CHAIN_ID });
const tx = LegacyTransaction.fromTxData(txData, { common });
const serializedUnsignedTransaction = `0x${Buffer.from(tx.serialize()).toString('hex')}`;
// 0xeb8085098bca5a00825208944c81fec94ab7a764667ad35b55190cbeb4cd4a3487470de4df82000080808080위 출력값은 트랜잭션 서명 가이드의 바이낸스스마트체인 예시 입력값과 동일합니다.
클레이튼(Kaia)
클레이튼은 caver-js로 빌드합니다. API별로 허용되는 트랜잭션 타입이 다르므로 아래 표를 참고하세요.
| 트랜잭션 타입 | 출금 신청 | 트랜잭션 서명 신청 |
|---|---|---|
TxTypeLegacyTransaction | ✅ | ✅ |
TxTypeValueTransfer | ✅ | ✅ |
TxTypeSmartContractExecution | ✅ (KIP-7/ERC-20 transfer 호출만) | ✅ (임의 호출 가능) |
TxTypeSmartContractDeploy | ❌ | ✅ |
TxTypeFeeDelegatedValueTransfer | ✅ | ✅ |
TxTypeFeeDelegatedSmartContractExecution | ✅ (transfer 호출만) | ✅ (임의 호출 가능) |
TxTypeFeeDelegatedSmartContractDeploy | ❌ | ✅ |
TxTypeEthereumDynamicFee | ❌ | ❌ |
- Legacy: 이더리움 호환 타입. 바이낸스스마트체인 예시처럼
@ethereumjs/tx의LegacyTransaction으로 빌드한 값도 사용할 수 있습니다. - FeeDelegated 계열 (수수료 대납): 신청 시
useFeeDelegation: true가 필수이며(반대로 대납 타입이 아니면useFeeDelegation을 켤 수 없습니다), 트랜잭션의feePayer는 비워 두거나 지갑의 수수료 주소로 설정해야 합니다. 대납 트랜잭션의 네트워크 수수료는 지갑의 수수료 주소가 부담하며, feePayer 서명은 옥텟이 추가합니다.
chainId는 Kaia 타입 트랜잭션의 직렬화 결과(RLP)에 포함되지 않으며, 서명 대상 계산 시 옥텟 서버가 플랫폼 설정값을 사용하므로 빌드 시 지정하지 않아도 됩니다. Legacy 타입에 포함된 chainId도 플랫폼 설정값으로 대체됩니다.
gasPrice는 ston 단위이며(1 ston = 10⁹ peb, 이더리움의 gwei에 해당) 지갑에 등록된 자산의 수수료 조회 API 응답의 withdrawalFee.gasPrice 값을 사용합니다.
네이티브 코인 전송
// caver-js: 1.8.1
import Caver from 'caver-js';
const caver = new Caver();
const fromAddress = '0xdc42f57984c0bd1594b9763f690a91c5416241db'; // 보내는 주소
const toAddress = '0x4c81fec94ab7a764667ad35b55190cbeb4cd4a34'; // 받는 주소
const amount = '0.02'; // 보낼 금액 (KAIA)
const nonce = 0; // 보내는 주소의 트랜잭션 카운트 (klay_getTransactionCount)
const gasPriceSton = '27.5'; // 가스 가격 (ston, 수수료 조회 API 응답의 withdrawalFee.gasPrice)
const tx = caver.transaction.valueTransfer.create({
from: fromAddress,
to: toAddress,
value: caver.utils.toPeb(amount, 'KLAY'),
gas: 21000,
gasPrice: caver.utils.numberToHex(caver.utils.toPeb(gasPriceSton, 'ston')),
nonce,
});
const serializedUnsignedTransaction = tx.getRLPEncoding();KIP-7(ERC-20) 토큰 전송
컨트랙트의 transfer(to, value) 호출을 ABI 인코딩하여 input에 넣고, to는 토큰 컨트랙트 주소로 설정합니다.
value(전송 금액)는 토큰의 decimals가 적용된 최소 단위 정수로 전달합니다.
// caver-js: 1.8.1
import Caver from 'caver-js';
const caver = new Caver();
const contractAddress = '0x59a6ab7c6ec2b1e70b47d90b0e094fa2f5e27a31'; // 토큰 컨트랙트 주소
const fromAddress = '0xdc42f57984c0bd1594b9763f690a91c5416241db'; // 보내는 주소
const toAddress = '0x4c81fec94ab7a764667ad35b55190cbeb4cd4a34'; // 받는 주소
const amount = '12500000'; // 보낼 금액 - 토큰 decimals가 적용된 최소 단위
const nonce = 0; // 보내는 주소의 트랜잭션 카운트 (klay_getTransactionCount)
const gasPriceSton = '27.5'; // 가스 가격 (ston)
const input = caver.abi.encodeFunctionCall(
{
name: 'transfer',
type: 'function',
inputs: [
{ type: 'address', name: '_to' },
{ type: 'uint256', name: '_value' },
],
},
[toAddress, amount],
);
const tx = caver.transaction.smartContractExecution.create({
from: fromAddress,
to: contractAddress,
value: 0,
input,
gas: 100000,
gasPrice: caver.utils.numberToHex(caver.utils.toPeb(gasPriceSton, 'ston')),
nonce,
});
const serializedUnsignedTransaction = tx.getRLPEncoding();Updated about 17 hours ago