트랜잭션 빌드 가이드

서명 대상이 되는 원본 트랜잭션(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)지원 트랜잭션 타입
이더리움111155111 (Sepolia)EIP-1559, Legacy
아비트럼42161421614 (Sepolia)EIP-1559, Legacy
폴리곤13780002 (Amoy)EIP-1559, Legacy
아발란체4311443113 (Fuji)EIP-1559, Legacy
바이낸스스마트체인5697Legacy만 (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')}`;
// 0x02f8710180843b9aca0085098bca5a00830186a094dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb0000000000000000000000004c81fec94ab7a764667ad35b55190cbeb4cd4a340000000000000000000000000000000000000000000000000000000000bebc20c0808080

Legacy — 바이낸스스마트체인

바이낸스스마트체인은 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/txLegacyTransaction으로 빌드한 값도 사용할 수 있습니다.
  • 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();

Did this page help you?