eth_sendRawTransaction 진입부터 멤풀 착지까지: 지난주 각자 보낸 tx 한 개가 geth 안에서 실제로 지나간 길을 코드로 따라갑니다.
기준 코드: go-ethereum ca1f2e4d3

지도

오늘 지나갈 경로

  1. 1
    rpc/

    eth_sendRawTransaction 이라는 글자가 Go 함수에 닿는다

  2. 2
    internal/ethapi/

    0x02f871... 이 Transaction 구조체가 된다

  3. 3
    eth/

    백엔드가 멤풀에 넘긴다

  4. 4
    core/txpool/

    라우터가 담당 서브풀을 고른다

  5. 5
    core/txpool/legacypool/

    수락과 거절이 결정된다

eth/backend.go:327

멤풀은 누가 만드나

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로 돌아서 앞에 있는 풀이 먼저 가져간다.
rpc/service.go:119

메서드가 등록되는 곳

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가 사라진다.
rpc/handler.go:553

요청이 메서드에 닿는 곳

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도 여기서 잡는다.
internal/ethapi/api.go:1738

바이트가 트랜잭션이 되는 곳

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. 영수증까지 기다리는 변형.
internal/ethapi/api.go:1640

정책 검사

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 입구에만 있다는 자백이다. 두 절 뒤에서 코드로 증명된다.
  • 해시를 받으면 채굴 보장인가: 아니다. 제출 성공의 증거일 뿐이다.
eth/api_backend.go:361

멤풀 직전

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 플래그. 풀 내부 정리를 기다리지 않는다.
core/txpool/txpool.go:318

라우터

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 구조
TxPool  (라우터)
트랜잭션을 하나도 갖고 있지 않다. 나누기만 한다

├─ LegacyPool
│   ├─ pending   실행 가능한 tx (계정별)
│   ├─ queue     아직 실행 불가능한 tx
│   ├─ all       해시로 찾는 색인
│   └─ priced    가격순 힙

└─ BlobPool   디스크 저장소. 타입 0x03 전용. 오늘은 열지 않는다

밖에서는 멤풀이 하나로 보이고, 안에서는 타입별로 나뉘어 있다. 이후 내용은 전부 LegacyPool 안 이야기다.

TxPool.Add 호출자

세 갈래가 여기서 합쳐진다

TxPool.Add 호출 경로
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 검사도 적용되지 않는다.

legacypool.go:556

일차 관문, 상태를 안 본다

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이 같은 함수를 쓴다. 다른 부분만 옵션으로 주입한다.
core/txpool/validation.go:63

무상태 검사 순서

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은 이 노드의 정책이다.
core/txpool/validation.go:267

이차 관문, 상태를 본다

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는 어차피 사라진다. 그대로 두고 더하면 이중 계산이 된다.
  • 왜 검사가 두 개뿐인가: 상태 조회가 필요한 게 그 둘뿐이다. 나머지는 무상태 쪽에서 이미 걸렀다.
legacypool.go:913

락은 최소한만

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에 있을 수 있다.
legacypool.go · list.go · queue.go

pending과 queue는 무엇인가

core/txpool/legacypool 구조체
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.go:666

계정 예약: 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.go:666

자리가 없으면 가격 경쟁: 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.go:666

교체인가 신규인가: 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% 인상이 어디서 갈리는지 눈으로 확인된다.
core/txpool/legacypool/list.go

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 사이클은 언제 도나: 새 블록이 오거나 승격 요청이 쌓이면 돈다. 정해진 주기가 아니다.
  • 그럼 얼마나 기다리나: 보통 즉시에 가깝다. 다만 코드에 보장은 없다는 게 요점이다.
정리

오늘 남길 세 가지

1. 싼 검사 먼저

validation.go 의 검사 순서: 비트 연산이 앞, ecrecover 가 뒤. legacypool.Add 도 같은 원칙이라 중복 확인이 검증보다 앞이다

2. 비싼 연산은 한 번만

types.Sender 결과가 tx 객체에 캐시되고, 이후 경로 전체가 그 캐시를 읽는다

3. 락은 최소한만

무상태 검증은 락 밖에서, 자료구조 조작만 락 안에서

세 원칙 모두 초당 수천 건이 들어오는 입구라는 조건에서 나왔다. 라인 번호는 다음 릴리스에 바뀌고 함수 이름도 바뀌지만, 이 셋은 다른 코드베이스에서도 쓰인다.