eth_sendRawTransaction 진입부터 멤풀 착지까지: 지난주 각자 보낸 tx 한 개가 geth 안에서 실제로 지나간 길을 코드로 따라갑니다.
기준 코드: go-ethereum ca1f2e4d3
오늘 지나갈 경로
- 1rpc/
eth_sendRawTransaction 이라는 글자가 Go 함수에 닿는다
- 2internal/ethapi/
0x02f871... 이 Transaction 구조체가 된다
- 3eth/
백엔드가 멤풀에 넘긴다
- 4core/txpool/
라우터가 담당 서브풀을 고른다
- 5core/txpool/legacypool/
수락과 거절이 결정된다
멤풀은 누가 만드나
legacyPool := legacypool.New(config.TxPool, eth.blockchain)
eth.blobTxPool = blobpool.New(config.BlobPool, eth.blockchain,
legacyPool.HasPendingAuth)
eth.txPool, err = txpool.New(config.TxPool.PriceLimit, eth.blockchain,
[]txpool.SubPool{legacyPool, eth.blobTxPool})legacypool과 blobpool을 만들고, 둘을 SubPool 슬라이스로 묶어 TxPool에 넘긴다. 부팅 때 한 번 실행된다.
TxPool은 검증을 하지 않는다. 타입별 정책이 다른 풀을 갈아끼울 수 있게 인터페이스로 받는다.
- [Go] `[]txpool.SubPool`: 인터페이스 슬라이스. 인터페이스는 메서드 목록이고, 그 메서드를 다 가진 타입은 뭐든 자격이 있다. 그래서
*LegacyPool과*BlobPool이 한 슬라이스에 담긴다. - blobpool.New가 왜 legacyPool.HasPendingAuth를 받나: EIP-7702 위임 계정은 두 풀이 함께 봐야 한다. 서브풀이 완전히 독립적이지는 않다는 예외.
- 슬라이스 순서가 중요한가: 중요하다. TxPool.Add가 first-match로 돌아서 앞에 있는 풀이 먼저 가져간다.
메서드가 등록되는 곳
func suitableCallbacks(receiver reflect.Value) map[string]*callback {
typ := receiver.Type()
callbacks := make(map[string]*callback)
for m := 0; m < typ.NumMethod(); m++ {
method := typ.Method(m)
if method.PkgPath != "" {
continue // method not exported
}
cb := newCallback(receiver, method.Func)
if cb == nil {
continue // function invalid
}
name := formatName(method.Name)
callbacks[name] = cb
}
return callbacks
}구조체의 메서드를 훑어 조건에 맞는 것만 콜백으로 등록한다. 부팅 때 한 번 돈다.
API가 늘어도 등록 코드를 고치지 않는다. 대신 규칙을 어겨도 컴파일이 통과한다.
- 채택 조건: newCallback이 판정한다. 대문자로 시작, 첫 인자가 context.Context면 허용, 반환은 없음 또는 error 하나 또는 값과 error 둘.
- 규칙을 어기면: 조용히 등록만 안 된다. 빌드는 성공한다. 메서드 이름 오타 하나로 API가 사라진다.
요청이 메서드에 닿는 곳
func (h *handler) handleCall(cp *callProc, msg *jsonrpcMessage) *jsonrpcMessage {
...
callb := h.reg.callback(msg.Method) // 등록표에서 조회
if callb == nil {
return msg.errorResponse(&methodNotFoundError{method: msg.Method})
}
args, pErr := parsePositionalArguments(msg.Params, callb.argTypes)
if pErr != nil {
return msg.errorResponse(&invalidParamsError{pErr.Error()})
}
...
return h.runMethod(rctx, msg, callb, args) // callback.call 로 이어진다요청이 올 때마다 돈다. 메서드 이름으로 콜백을 찾고, 인자를 등록된 타입으로 언마샬해 호출한다.
등록은 부팅 때 한 번, 조회는 요청마다. 앞 절이 표를 만들었다면 이 절이 그 표를 쓴다.
0x 문자열이 바이트가 되는 곳이 여기다. parsePositionalArguments가 callb.argTypes를 보고 hexutil.Bytes로 언마샬한다. 다음 절에서 SendRawTransaction 본문에 변환 코드가 없는 이유가 이것이다.
- 없는 메서드를 부르면: callb이 nil이라 methodNotFoundError. 등록이 안 된 메서드도 같은 에러라 오타와 구분이 안 된다.
- 실제 호출은 rpc/service.go:187 callback.call 에서 reflect로 일어난다. panic도 여기서 잡는다.
바이트가 트랜잭션이 되는 곳
func (api *TransactionAPI) SendRawTransaction(
ctx context.Context, input hexutil.Bytes) (common.Hash, error) {
tx := new(types.Transaction)
if err := tx.UnmarshalBinary(input); err != nil {
return common.Hash{}, err
}
// blob 사이드카 버전 변환 (blob tx만 해당)
...
return SubmitTransaction(ctx, api.b, tx)
}바이트를 Transaction 구조체로 디코딩하고 그대로 아래로 넘긴다. 판단은 하나도 하지 않는다.
핸들러를 얇게 유지한다. 정책은 SubmitTransaction, 유효성은 txpool의 몫이다.
- UnmarshalBinary는 타입을 어떻게 구분하나: EIP-2718 봉투. 첫 바이트가 타입 번호면 해당 구조체로, 아니면 legacy RLP로.
- 바로 아래 :1761 은 SendRawTransactionSync. 영수증까지 기다리는 변형.
정책 검사
func SubmitTransaction(ctx context.Context, b Backend,
tx *types.Transaction) (common.Hash, error) {
if err := checkTxFee(tx.GasPrice(), tx.Gas(), b.RPCTxFeeCap()); err != nil {
return common.Hash{}, err
}
if !b.UnprotectedAllowed() && !tx.Protected() {
return common.Hash{}, errors.New(
"only replay-protected (EIP-155) transactions allowed over RPC")
}
if err := b.SendTx(ctx, tx); err != nil {
return common.Hash{}, err
}
// sender 복원해서 Submitted transaction 로그
return tx.Hash(), nil
}수수료가 상한을 넘는지, 서명에 chainID가 들어 있는지 보고 백엔드로 넘긴다.
이 둘은 합의 규칙이 아니라 이 노드의 정책이다. 그래서 프로토콜이 아니라 RPC 입구에 있다.
- 왜 메서드가 아니라 일반 함수인가: 네 곳이 공유한다. SendTransaction(:1704), SendRawTransaction(:1744), SendRawTransactionSync(:1759), GraphQL(graphql.go:1402).
- 에러 메시지의 over RPC: 이 검사가 RPC 입구에만 있다는 자백이다. 두 절 뒤에서 코드로 증명된다.
- 해시를 받으면 채굴 보장인가: 아니다. 제출 성공의 증거일 뿐이다.
멤풀 직전
func (b *EthAPIBackend) SendTx(ctx context.Context,
signedTx *types.Transaction) error {
err := b.eth.txPool.Add([]*types.Transaction{signedTx}, false)[0]
if b.eth.localTxTracker == nil {
return err
}
if err != nil && !locals.IsTemporaryReject(err) {
return err // 영구적 거절
}
b.eth.localTxTracker.Track(signedTx) // 일시적이면 추적
return nil
}한 개짜리 슬라이스로 감싸 Add를 부르고, 일시적 거절이면 사용자에게 성공을 돌려주고 tracker에 맡긴다.
풀이 가득 찼다는 이유로 에러를 던지면 지갑이 재시도해야 한다. 노드가 대신 기억한다.
이건 로컬 tx 우대가 아니다. core/txpool/locals 주석이 직접 부인한다: 유효성도 가격도 계정 한도도 보지 않는다. 풀 진입 특혜가 아니라 풀 밖 재제출기다. 예전 geth는 실제로 우대했지만(가격 하한 우회, 축출 면제) locals 패키지로 분리되며 성격이 바뀌었다.
- 두 번째 인자 false 는: sync 플래그. 풀 내부 정리를 기다리지 않는다.
라우터
func (p *TxPool) Add(txs []*types.Transaction, sync bool) []error {
txsets := make([][]*types.Transaction, len(p.subpools))
splits := make([]int, len(txs))
for i, tx := range txs {
splits[i] = -1
for j, subpool := range p.subpools {
if subpool.Filter(tx) { // 이 타입은 내 담당인가
txsets[j] = append(txsets[j], tx)
splits[i] = j
break
}
}
}
for i := 0; i < len(p.subpools); i++ {
errsets[i] = p.subpools[i].Add(txsets[i], sync)
}Filter로 담당 서브풀을 찾아 분배하고, 에러를 입력 순서대로 되돌린다.
TxPool 자체는 아무 검증도 하지 않는다. 새 tx 타입이 생기면 서브풀 하나만 더하면 된다.
- splits는 왜 필요한가: 호출자는 errs[i]가 자기가 넣은 txs[i]의 결과라고 믿는다. 분배하면 순서가 흩어지니 기록해뒀다가 재조립한다.
- 아무도 안 받으면: splits[i]가 -1로 남고 ErrTxTypeNotSupported.
- Filter는 무거운가: 타입만 본다. 검증이 아니라 담당 여부만 답한다.
멤풀의 모양
TxPool (라우터)
트랜잭션을 하나도 갖고 있지 않다. 나누기만 한다
├─ LegacyPool
│ ├─ pending 실행 가능한 tx (계정별)
│ ├─ queue 아직 실행 불가능한 tx
│ ├─ all 해시로 찾는 색인
│ └─ priced 가격순 힙
└─ BlobPool 디스크 저장소. 타입 0x03 전용. 오늘은 열지 않는다밖에서는 멤풀이 하나로 보이고, 안에서는 타입별로 나뉘어 있다. 이후 내용은 전부 LegacyPool 안 이야기다.
세 갈래가 여기서 합쳐진다
RPC 경로 eth_sendTransaction · eth_sendRawTransaction · GraphQL
→ SubmitTransaction → SendTx
eth_resend → SendTx
p2p 경로 피어 전파 → txFetcher → eth/handler.go:202 addTxs
재제출 경로 core/txpool/locals/tx_tracker.go:227
모두 → TxPool.Add core/txpool/txpool.go:318세 경로가 전부 이 한 함수로 들어온다. RPC는 그중 하나이고, 트래픽 대부분은 p2p다.
p2p tx는 SubmitTransaction을 거치지 않는다. txfeecap도 EIP-155 검사도 적용되지 않는다.
일차 관문, 상태를 안 본다
func (pool *LegacyPool) ValidateTxBasics(tx *types.Transaction) error {
opts := &txpool.ValidationOptions{
Config: pool.chainconfig,
Accept: 0 |
1<<types.LegacyTxType |
1<<types.AccessListTxType |
1<<types.DynamicFeeTxType |
1<<types.SetCodeTxType,
MaxSize: txMaxSize,
MinTip: pool.gasTip.Load().ToBig(),
}
return txpool.ValidateTransaction(tx, pool.currentHead.Load(),
pool.signer, opts)
}이 풀이 받는 타입, 크기 상한, 최소 팁을 옵션으로 채워 공용 검증기를 부른다.
월드 스테이트를 조회하지 않는다. 그래서 락 없이 부를 수 있고, 쏟아지는 tx를 싸게 거른다.
Accept 비트마스크에 BlobTxType이 없다. 서브풀이 왜 분리되는지를 설명하는 가장 짧은 증거다.
- MinTip은 왜 무상태 쪽인가: 계정 상태가 아니라 이 노드의 정책이라 상태 조회가 필요 없다.
- 공용 검증기를 왜 따로 두나: legacypool과 blobpool이 같은 함수를 쓴다. 다른 부분만 옵션으로 주입한다.
무상태 검사 순서
if opts.Accept&(1<<tx.Type()) == 0 { ... } // 1 타입, 비트 연산
if tx.Size() > opts.MaxSize { ... } // 2 크기
rules := opts.Config.Rules(...) // 3 포크 활성 여부
if !rules.IsBerlin && tx.Type() != Legacy { ... }
if !rules.IsPrague && tx.Type() == SetCode { ... }
if tx.Value().Sign() < 0 { ... } // 4 정합성
if head.GasLimit < tx.Gas() { ... }
if tx.GasFeeCapIntCmp(tx.GasTipCap()) < 0 { ... }
from, err := types.Sender(signer, tx) // 5 서명, 제일 비싸다
intrGas, _ := core.IntrinsicGas(...) // 6 최소 가스
if tx.Gas() < intrGas { ... }
if tx.GasTipCapIntCmp(opts.MinTip) < 0 { ... } // 7 최소 팁타입, 크기, 포크, 정합성, 서명, 가스, 최소 팁 순으로 검사한다.
싼 검사가 앞, 비싼 검사가 뒤다. 불량 tx가 ecrecover까지 가지 못하고 탈락하는 순서다.
- 왜 서명 검증이 다섯 번째인가: ecrecover는 타원곡선 연산이라 이 목록에서 제일 비싸다. 비트 연산과 크기 비교로 걸러낼 수 있는 걸 다 걸러낸 다음 돌린다.
- 숨은 효과: 여기서 부른 Sender 결과가 tx 객체에 캐시된다. 이후 경로의 모든 Sender 호출은 캐시를 읽는다.
- 7번만 성격이 다르다: 1~6은 합의 규칙에 가깝고 7은 이 노드의 정책이다.
이차 관문, 상태를 본다
next := opts.State.GetNonce(from)
if next > tx.Nonce() { return ErrNonceTooLow }
balance := opts.State.GetBalance(from).ToBig()
cost := tx.Cost() // value + gas x feeCap
if balance.Cmp(cost) < 0 {
return ErrInsufficientFunds // 1단계: 이 tx 하나
}
spent := opts.ExistingExpenditure(from) // 계정 대기 tx 누적
if prev := opts.ExistingCost(from, tx.Nonce()); prev != nil {
need := spent + (cost - prev) // 교체면 증가분만
} else {
need := spent + cost // 신규면 누적에 더한다
}
if balance < need { return ErrInsufficientFunds } // 2단계검사는 두 가지뿐이다. nonce가 이미 쓴 값인지, 잔액이 감당 가능한지.
누적을 안 보면 잔액 1 ETH로 1 ETH짜리를 nonce 5와 6에 앉힐 수 있다. 둘 다 실행은 불가능하다.
- ExistingExpenditure가 왜 콜백인가: 이 파일은 legacypool과 blobpool이 공유한다. 누적 비용 계산 방식이 풀마다 달라서 정책은 풀이 주입하고 검증기는 공식만 적용한다.
- 교체일 때 왜 증가분만 보나: 기존 tx는 어차피 사라진다. 그대로 두고 더하면 이중 계산이 된다.
- 왜 검사가 두 개뿐인가: 상태 조회가 필요한 게 그 둘뿐이다. 나머지는 무상태 쪽에서 이미 걸렀다.
락은 최소한만
for i, tx := range txs {
if pool.all.Get(tx.Hash()) != nil { // 중복, 락 없음
errs[i] = txpool.ErrAlreadyKnown
continue
}
if err := pool.ValidateTxBasics(tx); err != nil { // 무상태, 락 없음
errs[i] = err
continue
}
}
pool.mu.Lock() // 여기서만 락
dirtyAddrs := pool.addTxsLocked(txs, errs)
pool.mu.Unlock()
done := pool.requestPromoteExecutables(dirtyAddrs) // 비동기 요청중복 확인과 무상태 검증을 락 밖에서 끝내고, 자료구조를 실제로 건드리는 부분만 락 안에 넣는다.
p2p로 초당 수천 개가 들어온다. 락 안에서 무거운 일을 하면 풀 전체가 직렬화된다.
- Sender를 여러 번 부르면 낭비 아닌가: 아니다. ValidateTxBasics 안의 첫 호출 결과가 tx 객체에 캐시된다. 이후는 캐시를 읽는다.
- 승격은 왜 요청만 하고 리턴하나: 승격이 끝날 때까지 기다리면 RPC 응답이 느려진다. 그래서 해시를 돌려준 시점에 tx는 아직 queue에 있을 수 있다.
pending과 queue는 무엇인가
type LegacyPool struct {
pending map[common.Address]*list // 지금 실행 가능한 tx
queue *queue // 아직 실행 불가능한 tx
all *lookup // 해시로 찾기
priced *pricedList // 가격순 힙
}
type queue struct {
queued map[common.Address]*list // 계정별 대기 목록
beats map[common.Address]time.Time // 계정별 마지막 활동 시간
}
type list struct {
strict bool // nonce가 연속이어야 하는가
txs *SortedMap // nonce로 정렬된 해시맵
totalcost *uint256.Int // 이 목록 전체 비용
}pending과 queue 모두 계정별 list를 담는다. 나누는 기준은 nonce 연속성 하나다.
실행 가능한 tx만 블록에 넣을 수 있다. 미리 갈라두면 블록 조립 때 고르는 비용이 줄어든다.
- 구조가 비대칭이다: pending은 map이 바로 필드에 있고, queue는 별도 타입으로 감싸져 있다. queue만 축출과 하트비트 관리가 필요해서 로직이 붙었다.
- strict가 뭔가: pending의 list는 strict=true다. nonce가 끊기면 뒤를 잘라낸다. queue의 list는 갭을 허용한다.
- totalcost는 어디서 쓰나: 이차 관문의 ExistingExpenditure가 이 값을 읽는다. 누적 잔액 검사의 출처다.
- 왜 all과 priced가 따로 있나: 조회 기준이 다르다. 해시로 찾을 때와 축출 대상을 고를 때.
계정 예약: legacypool.Add ①
if pool.all.Get(hash) != nil {
return false, txpool.ErrAlreadyKnown // 중복 재확인
}
if err := pool.validateTx(tx); err != nil { // 상태 기반 검증
return false, err
}
from, _ := types.Sender(pool.signer, tx) // 캐시에서 읽는다
if !hasPending && !hasQueued {
if err := pool.reserver.Hold(from); err != nil {
return false, err
}
defer func() {
if err != nil { pool.reserver.Release(from) }
}()
}중복을 한 번 더 보고, 상태 기반 검증을 돌리고, 이 계정을 legacypool 전속으로 예약한다.
한 계정이 두 서브풀에 동시에 있으면 nonce 관리가 깨진다. 예약으로 배타성을 보장한다.
- 중복 검사를 왜 또 하나: 앞의 검사는 락 바깥이었다. 락을 기다리는 사이 같은 tx가 먼저 들어왔을 수 있다.
- defer 안의 err는 어느 err인가: named return err다. 이후 단계에서 거절되면 그 값이 채워지고 deferred 함수가 읽어 예약을 해제한다. 원본 주석이 이 부분을 건드리지 말라고 경고한다.
- blobpool에 tx가 있는 계정이면: Hold가 실패하고 여기서 거절된다.
자리가 없으면 가격 경쟁: legacypool.Add ②
if uint64(pool.all.Slots()+numSlots(tx)) >
pool.config.GlobalSlots+pool.config.GlobalQueue {
if pool.priced.Underpriced(tx) {
return false, txpool.ErrUnderpriced // 최저가보다 싸면 탈락
}
...
drop, success := pool.priced.Discard(...) // 싼 것들을 축출
if !success {
return false, ErrTxPoolOverflow
}
}슬롯이 부족하면 풀 최저가와 비교한다. 더 싸면 거절, 더 비싸면 싼 것들을 쫓아내고 자리를 만든다.
멤풀은 유한하다. 무엇을 남길지 정하는 기준이 가격이다.
- 가스비 급등 때 내 tx가 사라지는 게 이건가: 그렇다. 이 Discard가 그 메커니즘이다.
- 미래 tx가 남의 tx를 쫓아낼 수 있나: 없다. 중간에 isGapped 검사가 있다. 실행 불가능한 tx가 실행 가능한 tx를 축출하는 건 금지다.
교체인가 신규인가: legacypool.Add ③
// 이미 pending에 같은 nonce가 있으면 교체
if list := pool.pending[from]; list != nil && list.Contains(tx.Nonce()) {
inserted, old := list.Add(tx, pool.config.PriceBump)
if !inserted {
return false, txpool.ErrReplaceUnderpriced
}
...
pool.queue.bump(from)
return old != nil, nil // 여기서 끝. queue를 거치지 않는다
}
// 교체가 아니면 queue로
replaced, err = pool.enqueueTx(hash, tx, true)pending에 같은 nonce가 있으면 제자리에서 교체하고 함수가 끝난다. 그 외는 queue로 간다.
이미 실행 가능한 자리를 잡은 tx의 교체라 다시 줄 설 이유가 없다.
지갑의 "수수료 올리기"가 바로 이 분기다. 그리고 "모든 tx가 queue를 거친다"는 설명의 반례이기도 하다: pending 교체는 동기적으로 즉시 반영된다.
- 그럼 RPC 응답이 pending 확정인 경우도 있나: 이 경로면 그렇다. 신규 tx는 아니다.
- 과제 연결: 이 지점에 로그를 심으면 5% 인상과 10% 인상이 어디서 갈리는지 눈으로 확인된다.
10% 규칙
old := l.txs.Get(tx.Nonce())
if old != nil {
if old.GasFeeCapCmp(tx) >= 0 || old.GasTipCapCmp(tx) >= 0 {
return false, nil // 하나라도 안 올랐으면 즉시 탈락
}
a := big.NewInt(100 + int64(priceBump))
aFeeCap := new(big.Int).Mul(a, old.GasFeeCap())
aTip := a.Mul(a, old.GasTipCap())
thresholdFeeCap := aFeeCap.Div(aFeeCap, big.NewInt(100))
thresholdTip := aTip.Div(aTip, big.NewInt(100))
// feeCap과 tip 둘 다 임계값 이상이어야 한다
if tx.GasFeeCapIntCmp(thresholdFeeCap) < 0 ||
tx.GasTipCapIntCmp(thresholdTip) < 0 {
return false, nil
}
}feeCap과 tip을 둘 다 올려야 하고, 그 위에 퍼센트 임계값도 넘겨야 교체가 성립한다.
절대값과 퍼센트를 이중으로 보는 이유는 Wei 단위 교체까지 정확히 처리하기 위해서다.
- 왜 인상을 요구하나. 0%면 왜 안 되나: 같은 값으로 무한히 교체할 수 있으면 공짜로 풀을 휘저을 수 있다. 인상 요구가 그 비용을 만든다.
- 10%는 프로토콜 규칙인가: 아니다. Config의 PriceBump 필드이고 기본값이 10이다. 운영자가 바꿀 수 있다. 노드마다 다를 수 있다는 게 멤풀이 합의 대상이 아니라는 이야기로 이어진다.
- 하나만 올리면: 첫 if에서 바로 탈락한다. 퍼센트 검사까지 가지도 않는다.
그럼 언제 pending이 되나
legacypool.go:913 Add
requestPromoteExecutables(dirtyAddrs)
|
| (별도 고루틴)
legacypool.go:1136 scheduleReorgLoop
legacypool.go:1208 runReorg
legacypool.go:1255 promoted := pool.promoteExecutables(promoteAddrs)
legacypool.go:1405 promoteExecutables
queue.go:162 queue.promoteExecutables(...)
nonce가 이어지면 queue 에서 pending 으로Add는 승격을 요청만 하고 리턴한다. 실제 승격은 별도 고루틴의 reorg 사이클에서 일어난다.
RPC 응답이 풀 정리를 기다리면 느려진다. 응답과 정리를 분리한다.
- reorg 사이클은 언제 도나: 새 블록이 오거나 승격 요청이 쌓이면 돈다. 정해진 주기가 아니다.
- 그럼 얼마나 기다리나: 보통 즉시에 가깝다. 다만 코드에 보장은 없다는 게 요점이다.
오늘 남길 세 가지
validation.go 의 검사 순서: 비트 연산이 앞, ecrecover 가 뒤. legacypool.Add 도 같은 원칙이라 중복 확인이 검증보다 앞이다
types.Sender 결과가 tx 객체에 캐시되고, 이후 경로 전체가 그 캐시를 읽는다
무상태 검증은 락 밖에서, 자료구조 조작만 락 안에서
세 원칙 모두 초당 수천 건이 들어오는 입구라는 조건에서 나왔다. 라인 번호는 다음 릴리스에 바뀌고 함수 이름도 바뀌지만, 이 셋은 다른 코드베이스에서도 쓰인다.