Parallel validation pipeline with BALs

Agradecimentos especiais a Ansgar, Marius, Francesco, Carl, Maria e Jochem pela contribuição e colaboração.

EIP-7928 (listas de acesso em nível de bloco, BALs) permite execução paralela e E/S em lote para validação de bloco, exigindo que os construtores declarem todos os locais de estado acessados ​​durante a execução do bloco, juntamente com suas alterações de estado. Isso permite que os clientes busquem previamente o estado e executem transações em paralelo.

No entanto, a especificação BAL atual poderia introduzir uma assimetria de validação que poderia ser explorada para forçar trabalho desnecessário durante a validação do bloco. Isso não interrompe a paralelização de execução, mas anula o benefício da pré-busca de E/S em lote, ao mesmo tempo que impõe largura de banda e custos de execução significativos.

DR: Os blocos inválidos podem não ser invalidados tão rapidamente quanto os blocos válidos do pior caso podem ser validados. Isso nos impede de escalar. O problema pode ser mitigado pelos clientes com uma simples verificação do orçamento do gás. Um projeto de PR contra o EIP pode ser encontrado aqui.

Para entender o problema e sua solução, vamos começar explicando como funcionam os BALs.

Antecedentes: Estrutura BAL

Um BAL é organizado por conta:

(address,
 storage_changes,   # slot → ((block_access_index, post_value))
 storage_reads,     # (slot)
 balance_changes,
 nonce_changes,
 code_changes)

Um BAL contém dois tipos distintos de informações:

  1. Diferenças de estado pós-transaçãoEscreve são anotados com block_access_indexentão sabemos exatamente quais transações alteram uma determinada parte do estado.

  2. Locais de estado pós-bloqueio (storage_reads e endereços sem *_changes) são não mapeados para índices de transação. Sabemos que eles são acessados ​​em algum lugar do bloco, mas não por qual transação.

Por que omitir índices de transação para leituras? Isso economiza largura de banda. Quando uma transação lê e grava um slot de armazenamento, a entrada de gravação em storage_changes inclui a leitura, evitando duplicação. Como a paralelização requer apenas o conhecimento de quais locais de estado são acessados ​​(e não quando), o índice de transação é uma sobrecarga desnecessária.

Esta assimetria é crítica para a compreensão da vulnerabilidade: as escritas podem ser validadas antecipadamente, mas as leituras não.

Validação de bloco com BALs

Após receber um bloco, os clientes utilizam o BAL para:

  • Paralelizar a execução de transações enquanto atrasa a validação completa do BAL até depois da execução
  • Pré-busca do estado obrigatório via E/S em lote para reduzir a latência do disco
  • Calcule a raiz pós-estado em paralelo com a execução

Antes dos BALs, a validação do bloco era simples: o pior caso era um bloco que simplesmente consumia todo o gás disponível. Você não poderia piorar as coisas ignorando a validade – os clientes abortariam quando o limite de gás fosse atingido e marcariam o bloco como inválido. Com os BALs, isso muda: devemos garantir que o tempo de execução do pior caso dos blocos válidos não seja pior do que o dos blocos inválidos.

O que pode ser validado antecipadamente

Diferenças de estado pós-transação pode ser aplicado durante a execução da transação. Como as gravações são mapeadas para índices de transação, cada transação pode ser validada independentemente em relação às mudanças de estado esperadas.

O que não pode ser validado antecipadamente

Locais de estado pós-bloqueio não pode ser validado durante a execução de transações individuais.

Um slot de armazenamento lido por transação x pode ser escrito por transação sim. Ao agregar o BAL final, as gravações consomem leituras (o slot aparece apenas em storage_changesnão storage_reads). Se um slot será declarado como leitura depende do conjunto completo de gravações em todas as transações.

Portanto, a validação das leituras declaradas requer a execução de todas as transações primeiro.

Estudo de caso: Geth

Geth, por exemplo, usa várias goroutines de trabalho que executam transações e validam suas diferenças de estado em paralelo. Para locais de estado (leituras), a validação é adiada para um ResultHandler goroutine que agrega resultados após a conclusão de todas as transações.

Na prática:

  • Os trabalhadores executam transações e validam diferenças de estado de forma independente
  • Fluxo de resultados para o ResultHandler que coleta slots de armazenamento acessados
  • Somente após a conclusão de todas as transações o manipulador poderá verificar as leituras declaradas em relação aos acessos reais

Esta validação adiada cria a oportunidade de ataque.

O Ataque

Agora vamos examinar o que um construtor de blocos malicioso poderia explorar.

Configurar

Deixar:

  • G = limite de gás de bloqueio
  • G_{\mathrm{tx}} = limite máximo de gás por transação (≈ 2^{24} sob EIP-7825)
  • g_{\mathrm{carga}} = 2100 = custo mínimo de gás de um resfriado SLOAD

Construção de Ataque

Um proponente adversário constrói um bloco em duas etapas:

Etapa 1: declarar leituras de armazenamento fantasma

Declarar um conjunto S de slots de armazenamento em storage_reads:

|S| = \left\lfloor \frac{G – G_{\mathrm{tx}}}{g_{\mathrm{sload}}} \right\rfloor

Este é o máximo número de leituras distintas de armazenamento refrigerado que poderiam caber no bloco se todo o gás restante (após reservar uma transação max-gas) fosse gasto em SLOADs.

Etapa 2: incluir transações somente de computação

Inclui uma ou mais transações cuja execução satisfaça:

  • Uso de gás ≈ G_{\mathrm{tx}}
  • Não há acesso a nenhum slot em S
  • Computação pura (por exemplo, loops aritméticos, hashing)

O BAL resultante é sintaticamente válido e respeita todos os limites de gás, mas as leituras de armazenamento declaradas nunca são acessadas durante a execução.

Por que isso é problemático

Os clientes começariam a pré-busca enquanto executavam transações em paralelo. Da perspectiva de qualquer transação única, eles não podem invalidar o bloco antes de terminar a execução todos transações.

O resultado: um bloco inválido que desativa totalmente a pré-busca de E/S útil, aumenta o BAL para centenas de KiB (0,6 MiB no limite de gás do bloco de 60M) e sobrecarrega a rede – tudo isso enquanto sua invalidação ocorre tarde demais no processo.

Isso significa efetivamente que não podemos escalar tanto quanto o pior cenário válido blocos permitiriam, mas estamos limitados por blocos inválidos.

Por que não declarar leituras exageradas? Se o invasor declarou \left\lfloor \frac{G}{g_{\mathrm{sload}}} \right\rfloor slots de armazenamento (usando o orçamento total de gás), os clientes poderão invalidar o bloco assim que qualquer transação desperdiçar gás em operações que não acessam o armazenamento. Mas, ao deixar espaço para pelo menos uma transação de tamanho máximo, nenhuma transação pode invalidar o bloco de forma independente e antecipada.

Confira BALrogum proxy simples para a API do mecanismo que injeta BALs de pior caso em blocos, útil para testes.

Solução: Verificação de viabilidade do orçamento de gás

O ataque aproveita dois fatos:

  1. Threads individuais não sabem o que está acontecendo em outros threads
  2. As localizações dos estados só são validáveis ​​após a execução de todas as transações

No entanto, podemos explorar uma restrição fundamental: a primeiro acesso para custos de slot de armazenamento pelo menos o frio SLOAD custo (gás 2100, ou gás 2000 com listas de acesso EIP-2930, valor final depende de EIP-7981).

O Invariante

Periodicamente durante a execução (por exemplo, a cada 8 transações), o cliente verifica:

  • Quanto gás foi usado
  • Quais slots de armazenamento declarados foram acessados
  • Quantas leituras declaradas permanecem

Então, deixe:

  • R_{\mathrm{declarado}} = número de leituras de estado declaradas no BAL
  • R_{\mathrm{visto}} = número já acessado
  • R_{\mathrm{restante}} = R_{\mathrm{declarado}} – R_{\mathrm{visto}}
  • G_{\mathrm{restante}} = gás de bloco restante

Uma condição necessária para a validade do BAL é:

G_{\mathrm{restante}} \ge R_{\mathrm{restante}} \cdot 2100

Se esta desigualdade falhar, o bloco pode ser rejeitado imediatamente.

Confira o rascunho do PR para esta especificação aqui.

Benefícios

A verificação do orçamento de gás oferece rejeição antecipada de blocos maliciosos após apenas uma única transação de gás máximo (~16M de gás/~1 segundo). A execução paralela permanece inalterada, preservando todos os benefícios da paralelização. O formato BAL não requer alterações e a E/S em lote é totalmente restaurada para blocos válidos. A complexidade da implementação é mínima – apenas uma contabilidade simples no manipulador de resultados existente – com sobrecarga de desempenho insignificante devido a verificações aritméticas periódicas.

Alternativas consideradas

Uma abordagem alternativa é anotar os acessos somente leitura com um índice de transação de primeiro acesso, o que tornaria os BALs autodescritivos e simplificaria a lógica de validação. A abordagem do orçamento de gás alcança propriedades de rejeição antecipada semelhantes, mas com contabilização adicional durante a execução, em vez de dados adicionais no BAL. O mapeamento de índices para leituras adicionaria uma média de 4% de dados adicionais (compactados) ao BAL.

Outra alternativa seria ordenar as localizações dos estados no BAL pelo momento em que ocorre seu acesso no bloco. No entanto, isto introduziria complexidade adicional, bem como tornaria mais difícil provar coisas contra o BAL.

A verificação de viabilidade do orçamento de gás fornece uma mitigação simples e eficaz: ao verificar se o gás restante pode cobrir as leituras declaradas restantes, os clientes podem rejeitar blocos maliciosos antecipadamente, sem alterar o formato BAL ou sacrificar os benefícios da paralelização.

Exemplo de lógica do ResultHandler

MIN_GAS_PER_READ = 2100  # cold SLOAD cost
CHECK_EVERY_N_TXS = 8

def result_handler(block, bal, tx_results_channel):

    # Count expected storage reads from the BAL (once, upfront)
    expected_reads = sum(
        len(acc.storage_reads) for acc in bal.accounts
    )

    accessed_slots = set()
    total_gas_used = 0

    for i, result in enumerate(tx_results_channel, start=1):
        # Merge this transaction's accessed slots
        accessed_slots.update(result.accessed_slots)
        total_gas_used += result.gas_used

        # Periodic feasibility check
        if i % CHECK_EVERY_N_TXS == 0:
            remaining_gas = block.gas_limit - total_gas_used
            unaccessed_reads = expected_reads - len(accessed_slots)
            min_gas_needed = unaccessed_reads * MIN_GAS_PER_READ

            if min_gas_needed > remaining_gas:
                raise Exception(
                    "BAL infeasible: "
                    f"{unaccessed_reads} reads need {min_gas_needed} gas, "
                    f"only {remaining_gas} left"
                )

Recursos

Fontesethresear

By victor

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *