Todos os artigos

Série Ledger, Parte II: Saldos e imutabilidade

·
15 de set. de 2026
·
17 min de leitura
·
••• visualizações
·
••• curtidas

Traduzido do artigo original em inglês. Ler em inglês

Série Ledger, Parte II: Saldos e imutabilidade

A Parte 1 desta "série ledger" terminou em um mundo mais simples, onde toda movimentação era liquidada assim que era registrada. O problema agora é que dinheiro de verdade não se comporta assim. O Tobi, por exemplo, é um dos seus usuários, ele tem 100 USDC no seu app, outros 50 USDC estão parados em um depósito que ainda não foi confirmado, e um saque de 30 USDC está a caminho de uma carteira externa. O que o saldo dele deveria mostrar nesse momento, e quanto disso ele realmente pode gastar?

Dependendo do que o app conta, ele pode mostrar 100, 120 ou 150, e a resposta honesta para quanto ele pode gastar é 70. Esse intervalo entre o que está registrado, o que é esperado e o que dá para gastar naquele momento é o assunto desta parte II.

Por que um único saldo não basta mais

Nada no modelo de partidas dobradas muda aqui. Toda movimentação ainda fecha, os lançamentos ainda são escritos uma vez e nunca mais tocados. O que muda é que agora o dinheiro passa um tempo em estados diferentes. Um pagamento com cartão leva dias para ser liquidado, um depósito cripto fica sem confirmação até as confirmações de bloco chegarem a um certo número definido, um payout fica em trânsito com um provedor que pode falhar, ninguém sabe.

O saldo único da Parte 1 só consegue descrever o dinheiro que chegou. Ele não tem como descrever dinheiro que ainda não foi liquidado ou que está em trânsito, então sistemas que guardam um número só acabam ou escondendo dinheiro que é do usuário ou liberando dinheiro que ainda nem está sob custódia. O sistema também precisa registrar dinheiro em estado pendente.

Os três saldos

Um ledger que modela dinheiro em trânsito responde com três números, e todos eles continuam sendo só somas, sobre os lançamentos e os depósitos que ainda estão esperando para ser lançados.

  1. Lançado (Posted): É a soma de toda movimentação liquidada em todas as contas do Tobi, a carteira dele mais qualquer conta que guarde dinheiro reservado para os saques dele, os fatos finais. O saldo lançado do Tobi é 100 USDC.

  2. Pendente (Pending): Esse é o que mais confunde na maioria das vezes, porque "saldo pendente" é usado para dizer duas coisas diferentes. Uma é só a atividade pendente, +50 USDC entrando e -30 USDC saindo, 80 USDC em trânsito, um líquido de +20. A outra é o lançado mais essa atividade, 100 + 50 - 30 = 120 USDC, o valor em que o saldo provavelmente vai fechar. São duas perguntas diferentes, nunca misture as duas em um único endpoint.

  3. Disponível (Available): É o que o Tobi realmente pode gastar agora. O valor lançado menos os valores saindo, 100 - 30 = 70 USDC. Dinheiro entrando que ainda não foi liquidado não está disponível, os 50 USDC ainda sem confirmação on chain não existem para gasto nesta carteira até serem liquidados.

Aqui o disponível é um número só porque esta carteira roda uma única política. Muitos sistemas dividem isso, o que o Tobi pode gastar e o que ele pode sacar acabam virando dois números diferentes. Um depósito com cartão pode ficar disponível para gastar dentro do app na hora e continuar bloqueado para pagamentos por dias, porque senão alguém deposita com um cartão roubado, tira tudo como cripto no mesmo instante, e quem fica com o chargeback na mão é você. Algumas empresas vão pelo caminho oposto e concedem crédito temporário, dinheiro liberado antes de ser liquidado com base em alguns parâmetros, é assim que depósitos instantâneos funcionam, e nos EUA a Regulation CC chega a definir os prazos que os bancos têm que seguir para quando os fundos depositados ficam disponíveis. Saldos de merchants ainda carregam reservas de caixa e bloqueios de risco em cima de tudo isso.

Mas crédito temporário tem um preço. Você credita o Tobi no momento em que o depósito aparece, DR deposits_in_transit 50 / CR tobi:wallet 50, ele gasta, o depósito falha por algum motivo estranho, e agora a carteira dele está negativa. Então o ledger tem que permitir saldos negativos em contas específicas por design, e registrar isso como um recebível, dinheiro que o Tobi agora te deve. E é daí que vêm as suas perdas com fraude e o seu risco de crédito, então você decide de antemão quais tipos de conta podem ficar negativos. Alguns sistemas de ledger, como o que a gente construiu, por exemplo, têm uma flag exatamente para isso, debits_must_not_exceed_credits, ative ela em uma conta e qualquer transferência que faria os débitos dela passarem dos créditos é rejeitada.

Seguindo o dinheiro

Agora, pegue as movimentações do Tobi uma de cada vez. Assim como na Parte 1, cada uma ainda é registrada como uma transação equilibrada.

O depósito de 50 USDC dele é detectado com uma confirmação. As moedas estão on chain, mas não são finais, como você sabe, a rede ainda pode descartar o bloco que as contém e colocar outro no lugar, o que se chama "reorg", é dinheiro que você consegue ver mas ainda não tem. Nada é lançado no ledger neste ponto. Em vez de um lançamento, o depósito ganha o seu próprio histórico append only, toda vez que algo nele muda, uma nova confirmação, um novo status, é uma nova linha, e o saldo pendente do Tobi lê os 50 de lá. Nenhum lançamento toca a custódia ou a carteira dele. A custódia é o que é conciliado com o saldo finalizado da chain, que é exatamente o que auditores e sistemas de proof of reserve checam, como padrão, claro.

Saldos agora: lançado 100, atividade pendente +50, disponível 100.

O depósito só é lançado quando a chain o considera final, e quando o registro do seu provedor de custódia sobre aquele depósito exato concorda. Cheque depósito por depósito. Se você comparar só com o saldo total do provedor, outro depósito chegando ao mesmo tempo pode fazer o total parecer certo enquanto esses 50 ainda estão faltando. Aí é uma transação:

DR custody       50
CR tobi:wallet   50

Agora, no mesmo commit o histórico do depósito ganha a sua linha de crédito e os 50 passam de pendente para disponível. Saldos agora: lançado 150, atividade pendente 0, disponível 150. Nada foi editado, o ledger simplesmente ficou sabendo desse depósito agora, quando ele já era final.

O que "final" significa depende da chain. Doze confirmações é coisa de proof of work. No proof of stake do Ethereum o sinal de verdade é o checkpoint finalizado, duas a três epochs, ~15 minutos num dia normal. A Solana tem os seus próprios níveis de commitment, e desde que o upgrade Alpenglow entrou no ar em outubro de 2026, confirmed e finalized significam a mesma coisa. Em um rollup como Base ou OP, o sequencer confirmar a sua transação é só o primeiro passo, a finalidade na L1 vem ~20 minutos depois. A finalidade acaba sendo um piso que você define por chain, e todo depósito deveria registrar qual piso o liquidou, para que no dia em que uma chain se comportar mal você saiba exatamente quais depósitos foram creditados sob qual regra. E se o provedor considerar um depósito completo enquanto ele ainda está abaixo do seu piso, você segura para revisão e não credita nada. Se você usa um sistema como a Fireblocks, é só configurar isso no seu dashboard.

Agora, um reorg antes da finalidade é o caso simples, e o depósito não falhou. A transação normalmente volta para a mempool e entra de novo em um bloco posterior, então o depósito só volta para não confirmado e a contagem de confirmações começa do zero. Marque ele como falho nesse ponto e uma de duas coisas acontece quando ele entrar de novo, ou o seu sistema ignora e o Tobi nunca é creditado enquanto a custódia fica com moedas que você não deve a ninguém, ou ele aparece parecendo um depósito novinho e o Tobi é creditado duas vezes. Um depósito só falha quando dá para provar que ele sumiu, no Ethereum, por exemplo, quando outra transação do mesmo remetente usou aquele nonce e essa é final, e mesmo assim não tem nada para reverter, porque o ledger nunca viu ele.

Esse segundo caso é o motivo de a chave de um depósito importar tanto. É possível que uma transação carregue várias transferências de USDC, então a chave é a chain, o hash da transação e a posição da transferência dentro dessa transação. No Ethereum, essa é a posição dela no receipt da transação. Não use logIndex nesse caso, ele conta ao longo do bloco inteiro, então muda no momento em que uma transação de um bloco descartado entra em outro. E a unique constraint que garante que cada depósito seja creditado uma vez fica na transação do ledger que lança ele. Coloque ela no histórico e ela rejeita a segunda linha de confirmação, já que toda linha ali carrega a mesma chave.

Voltando ao Tobi, os 50 dele passaram. Agora ele pede um saque de 30 USDC. Antes de qualquer provedor ser chamado, o dinheiro é reservado:

DR tobi:wallet        30
CR tobi:withdrawals   30

Saldos agora: lançado 150, atividade pendente -30, disponível 120. O lançado ainda é 150 porque ele lê todas as contas do Tobi, a carteira caiu para 120 e a conta de saques ficou com os 30. A reserva é uma partida dobrada de verdade, e é por isso que o disponível cai antes de o saque ser lançado, os 30 já estão comprometidos, mesmo sem ter saído do prédio.

Você deve ter reparado que o depósito ficou fora do ledger até ser final, enquanto o saque entrou no ledger antes de qualquer coisa sair. Tudo se resume ao que cada um faz com o disponível. A reserva tira do que o Tobi pode gastar, então ela tem que ser commitada junto com a checagem de saldo, senão duas requisições podem gastar os mesmos 30. O depósito pendente ainda não adiciona nada que ele possa gastar, então ele pode esperar do lado de fora até ser final. O crédito provisório é a única exceção, e é por isso que o depósito, nesse caso, é lançado na hora.

O provedor entrega, e o payout é concluído:

DR tobi:withdrawals   30
CR custody            30

Saldos agora: lançado 120, atividade pendente 0, disponível 120. A conclusão só limpa a reserva, a custódia paga o rail e a carteira fica intocada. Se o lançamento descontasse o disponível de novo, o Tobi veria 150 virar 90 pagando 30.

Agora, eu deixei esse saque limpo de propósito, ele reservou 30 e liquidou 30. Os de verdade raramente fazem isso. O gas só é conhecido quando a transação é minerada, os provedores cobram as próprias taxas, e o gas de uma transferência de USDC é pago em ETH, um ativo diferente. Então o valor que você segura e o valor que finalmente é liquidado quase nunca batem, e você reserva um limite superior, os 30 mais o máximo que a taxa poderia ser, digamos 2:

DR tobi:wallet        32
CR tobi:withdrawals   32

Depois liquida os valores reais e libera o resto, em uma transação:

DR tobi:withdrawals      32
CR custody            30.00
CR revenue:fees        0.40
CR tobi:wallet         1.60

O disponível caiu 32 enquanto estava em trânsito e termina em 119.60, e a conta de saques continua zerando no líquido. O gas que a rede realmente cobrou é despesa sua, paga com o seu próprio ETH em uma movimentação separada, DR expense:gas / CR treasury:eth, e ela fecha sozinha porque a checagem da Parte 1 roda por ativo. Os 0.40 agora são receita sua, então eles também são retirados da custódia dos clientes, DR treasury:usdc 0.40 / CR custody 0.40. Você mantém a custódia só para dinheiro de cliente. Se você pagar gas com ela, cada saque come um pouquinho do dinheiro de todo mundo, e o primeiro lugar em que isso vai aparecer é o seu cron de reservas, o que você tem por ativo contra o que você deve aos usuários, voltando com falta.

Rails de cartão deixam isso ainda mais difícil:

O que acontece quando o saque falha

Isso pode ir por três caminhos, e só dois deles são falhas.

Um erro simples: o saque pendente é rejeitado antes de ser lançado, talvez o endereço não passe na triagem. A reserva é liberada:

DR tobi:withdrawals   30
CR tobi:wallet        30

O disponível volta para 150, o lançado nunca se mexeu porque o dinheiro ficou.

O caro: o payout foi lançado, a chain confirmou, e dias depois a exchange para onde ele foi devolve tudo. Essa devolução é uma nova movimentação financeira por si só, e on chain é só mais uma transferência entrando, então ela segue o mesmo caminho de qualquer depósito, o seu próprio histórico, o seu piso de finalidade, casada com o payout exato que ela está devolvendo, e só então ela é lançada. Lance também o que realmente chegou, a exchange que está devolvendo pode ficar com a própria taxa, então saíram 30 e talvez voltem 29.50 para você:

DR custody       29.50
CR tobi:wallet   29.50

O payout original continua no histórico, e a devolução também.

E aí tem o terceiro caminho, o que faz a maioria das pessoas tomar prejuízo: o provedor deu timeout e ninguém sabe se o payout chegou. Você não pode tratar um status desconhecido como falha. Trate como falha, libere os 30, ele chega on chain mesmo assim, e simples assim você pagou duas vezes, parabéns!

O padrão de verdade é que um resultado desconhecido fica pendente até o provedor responder.

Mas alguns provedores nunca respondem, depende de como o sistema deles foi construído. Então o desconhecido também não pode ficar pendente para sempre, ele desce uma escada, e cada degrau ou resolve com uma transação no ledger ou passa para o próximo:

  1. Pergunte o status diretamente ao provedor "Um requery", não fique só esperando o webhook.
  2. Cruze com os registros do provedor e com a própria chain, você escolhe.
  3. Dê baixa como perda, com uma aprovação no seu backoffice.

E para sequer saber que algo está travado, uma conta transitória não pode ser um número só. Digamos que o Tobi mande dois saques de 30, um é liquidado e o outro nunca. tobi:withdrawals mostra 30, e como os dois eram de 30, nada nesse número te diz qual deles está travado. Então todo lançamento que liquida ou libera alguma coisa aponta para o lançamento exato que ele fecha, isso é open item matching, emprestado direto de contas a receber, e um alerta dispara para qualquer coisa ainda aberta depois do SLA do seu rail.

O histórico fica

Olhando para tudo o que o dinheiro do Tobi fez acima, nenhum lançamento existente foi atualizado, e nem uma única linha do histórico do depósito. O estado mudou porque novas transações chegaram por cima das antigas, e essa é a segunda metade deste artigo.

Esse tipo de sistema é feito para receber correções, um lançamento com um zero a mais, uma movimentação registrada na conta errada. A tentação às 2h45 da madrugada é um UPDATE. Você corrige dados com uma txn de correção, nunca com um update de linha. A correção tem que espelhar a original com os lados invertidos e apontar de volta para ela, carregando um motivo e uma origem, para que o registro se explique sozinho quando alguém olhar:

function reverse(original: Txn, reason: string, source: string): Txn {
  return {
    id: randomUUID(),
    referenceId: original.id,
    effectiveAt: original.effectiveAt,
    reason,
    source,
    entries: original.entries.map((e) => ({
      accountId: e.accountId,
      side: e.side === "DR" ? "CR" : "DR",
      amountMinor: e.amountMinor,
      assetId: e.assetId,
    })),
  };
}

Todo campo é copiado pelo nome de propósito, espalhe a entry original com ...e e a correção acaba levando o id e os timestamps da original também. As duas txn's juntas zeram no líquido, e o saldo final nunca esconde como aquilo aconteceu. O suporte consegue refazer o histórico do Tobi, primeiro as linhas do próprio depósito e depois o ledger, e ver depósito, confirmação, reserva, liberação, em ordem. O seu db garante isso do mesmo jeito que a regra da Parte 1 garantia, um UPDATE ou DELETE contra uma tabela de lançamentos é rejeitado! Sem pensar duas vezes.

Uma coisa que o reverse() do jeito que está escrito não impede é reverter a mesma transação duas vezes, rode duas vezes e o Tobi recebe o dinheiro de volta duas vezes. As reversões de uma transação nunca podem somar mais do que a original. Um simples índice único consegue barrar uma segunda reversão total, mas ele não soma nada, então uma reversão total mais uma parcial ainda passam. A checagem de verdade lê o que já foi revertido e grava a nova reversão como uma única txn atômica, e isso é problema da Parte III, sobre o qual vamos falar.

O saldo guardado tem que concordar

A Parte 1 disse que o saldo guardado é um cache que tem que concordar com o log. Com três saldos, o risco aumenta, porque é o cache que aprova os gastos. Um saldo usado para aprovar um saque tem que refletir as reservas e os lançamentos já commitados, e ele tem que mudar no mesmo commit que grava os lançamentos, senão uma leitura no meio do caminho vê dinheiro que já está prometido.

O teste da Parte 1 continua valendo, jogue fora todo número guardado e refaça o ledger mais o histórico de depósitos, e você recebe os mesmos três saldos de volta, e a parte pendente sempre pode ser checada contra uma varredura nova da chain. Com os checkpoints lá de cima, o replay começa no último checkpoint verificado em vez do primeiro lançamento já escrito, e é isso que mantém esse teste viável em escala. E quando um número guardado e um número refeito discordam, um job de conciliação compara os dois e alerta sobre a divergência. Aí você descobre qual dos dois está mentindo. Se os lançamentos estão certos e o cache está errado, você reconstrói o cache a partir dos lançamentos, nenhuma movimentação financeira nova cabe nessa correção. Se os próprios lançamentos estão errados, você lança uma txn de correção como a de cima. De um jeito ou de outro, você investiga antes de confiar no número de novo. Esse alerta não é negociável.

OBSERVAÇÃO: um saldo guardado é uma soma, e somas precisam de espaço. O USDC tem 6 casas decimais no Ethereum e 7 na Stellar, o ETH tem 18. Um inteiro de 64 bits com sinal chega no máximo a uns 9.22 × 10¹⁸, em wei isso dá só ~9.22 ETH, um depósito razoável. O bigint do Postgres é exatamente esse inteiro de 64 bits, a mesma palavra que o bigint do TypeScript da Parte 1, com um limite bem diferente. Use valores de 128 bits, que é exatamente o que a gente faz, o que no Postgres significa numeric com escala 0, mantenha bigint em vez de number no TypeScript, e guarde a escala de cada ativo junto com o ativo, 30000000 não significa nada até você saber que é USDC com 6 casas decimais.

Agora, sobre como o próprio commit continua seguro sob carga alta, o locking, etc, isso fica para a Parte III.

Qual era o saldo naquele momento?

Existem dois relógios em toda movimentação, quando ela passou a valer e quando o seu sistema a registrou. Uma liquidação de cartão pode valer desde ontem e chegar hoje, o webhook de um provedor pode aparecer horas atrasado. Reconstrua hoje o saldo de ontem e ele vai conter movimentações das quais o sistema ainda não tinha ouvido falar ontem. Esse número é o que você sabe agora sobre ontem, não o que você sabia naquela hora.

Correções e devoluções também têm que escolher uma data efetiva cada uma. Uma correção de um erro de lançamento carrega a data de criação da original, porque a original nunca foi verdade, e é por isso que o reverse() acima copia original.effectiveAt. Uma devolução carrega a data de hoje, porque o payout realmente aconteceu e o dinheiro realmente voltou depois. Basta inverter essas duas uma vez e todo saldo consultado por data naqueles dias sai muito errado, te custando de novo.

Depois vêm os períodos fechados. Depois que um período é fechado, extratos enviados, relatórios entregues, juros apropriados, um lançamento atrasado que vale dentro dele não pode mais mudar esses números. Você lança ele no período aberto como um ajuste fora do período, com uma data efetiva apontando para trás, os bancos tradicionais chamam isso de data valor, a partir de quando conta, e data de lançamento, quando foi lançado. E um lançamento com data retroativa significa que juros ou taxas foram calculados sobre o saldo errado, então o recálculo também entra como uma nova transação de ajuste.

O que isso ainda não garante

O modelo aqui está certo, temos três saldos, histórico append only, cache concordando. Agora dois saques de 80 USDC cada chegam com um milissegundo de diferença. Os dois leem disponível 120, os dois passam na checagem, os dois reservam. 160 saindo de 120, e o histórico vai simplesmente registrar como você perdeu isso.

Nada acima impede isso, e nada acima estava errado. Saldos e histórico descrevem dinheiro, eles não protegem a escrita, duas requisições ainda podem ler o mesmo disponível e as duas escreverem em cima dele. Tornar essa escrita atômica, a checagem e a reserva commitando juntas ou não commitando nada, é o assunto da Parte III, Transações e Atomicidade.

•••
Compartilhe este artigo:
Série
Série Ledger

Fique por dentro

Receba os artigos mais recentes no seu e-mail.