Skip to content

accounting: fill client fee ledger gaps - #768

Merged
Roasbeef merged 15 commits into
mainfrom
accounting-ledger-reporting
Jun 24, 2026
Merged

accounting: fill client fee ledger gaps#768
Roasbeef merged 15 commits into
mainfrom
accounting-ledger-reporting

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we close the client fee-ledger gaps that would make production accounting hard to reconcile. Round settlement now records boarding fees, refresh and recipient outflows, and wallet sweep clearing legs; unroll now emits the confirmed unilateral exit cost after the final sweep lands.

We also add an internal DB-backed accounting report command under internal/cmd/tools/accounting. It reads sqlc report projections directly from the daemon SQLite DB, can output text or JSON, and can ask CoinGecko for an optional BTC/fiat balance view without a Faraday dependency.

Checks

  • make sqlc
  • make fmt-changed
  • go test ./unroll ./ledger ./vtxo ./darepod
  • go test ./...
  • make lint-changed-local
  • make doc-check (exits 0; reports existing mirror divergences in baselib/actor, db/actordelivery, p-models/durableactor, and p-models/durableactor/bridge)
  • make commitmsg-lint range="origin/main..HEAD"

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces comprehensive wallet sweep accounting, updates the round idempotency index, and adds a standalone admin reporting command for the client-side accounting ledger. Feedback on these changes highlights three key areas for improvement: first, the operator fee calculation in round/transitions.go should include cooperative leave outputs (intents.Leaves) to prevent fee inflation; second, the ListClientAccountBalances query in fee_accounting.sql should be optimized using correlated subqueries instead of an OR join to avoid full table scans; and third, the accounting tool should verify the existence of the SQLite database file before opening it to prevent silent file creation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread round/transitions.go
Comment on lines +3512 to 3525
if len(intents.VTXOs) > 0 {
for i := range intents.VTXOs {
amt := int64(intents.VTXOs[i].Amount)
if amt > 0 {
outputsSat += amt
}
}
} else {
for _, v := range ownedVTXOs {
if v != nil {
outputsSat += int64(v.Amount)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The operator fee calculation currently ignores cooperative leave outputs (intents.Leaves). Under the Ark round model, the client's operator fee is the difference between their contributed inputs and all requested outputs (including cooperative leaves). Ignoring intents.Leaves will cause the calculated operator fee to be incorrectly inflated by the entire cooperative leave amount. Summing intents.Leaves and adding them to outputsSat resolves this issue.

	if len(intents.VTXOs) > 0 {
		for i := range intents.VTXOs {
			amt := int64(intents.VTXOs[i].Amount)
			if amt > 0 {
				outputsSat += amt
			}
		}
	} else {
		for _, v := range ownedVTXOs {
			if v != nil {
				outputsSat += int64(v.Amount)
			}
		}
	}

	for i := range intents.Leaves {
		amt := intents.LeaveAmount(i)
		if amt > 0 {
			outputsSat += amt
		}
	}

Comment on lines +294 to +310
-- name: ListClientAccountBalances :many
SELECT a.account_id,
a.account_name,
a.account_type,
CAST(COALESCE(SUM(
CASE
WHEN le.debit_account = a.account_id THEN le.amount_sat
WHEN le.credit_account = a.account_id THEN -le.amount_sat
ELSE CAST(0 AS BIGINT)
END
), 0) AS BIGINT) AS balance_sat
FROM accounts AS a
LEFT JOIN ledger_entries AS le
ON le.debit_account = a.account_id
OR le.credit_account = a.account_id
GROUP BY a.account_id, a.account_name, a.account_type
ORDER BY a.account_id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using an OR condition in a LEFT JOIN (e.g., le.debit_account = a.account_id OR le.credit_account = a.account_id) prevents SQLite from efficiently utilizing indexes on debit_account and credit_account, resulting in full table scans. Since ledger_entries can grow very large in production, this query will become a performance bottleneck. Rewriting the query to use two correlated subqueries allows SQLite to perform highly efficient index lookups.

-- name: ListClientAccountBalances :many
SELECT a.account_id,
       a.account_name,
       a.account_type,
       CAST(
           COALESCE((SELECT SUM(amount_sat) FROM ledger_entries WHERE debit_account = a.account_id), 0) -
           COALESCE((SELECT SUM(amount_sat) FROM ledger_entries WHERE credit_account = a.account_id), 0)
       AS BIGINT) AS balance_sat
FROM accounts AS a
ORDER BY a.account_id;

Comment thread internal/cmd/tools/accounting/main.go Outdated
Comment on lines +132 to +134
if *sqlitePath == "" {
return fmt.Errorf("--sqlite is required")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

By default, opening a non-existent file path with SQLite will silently create an empty database file. If a user makes a typo in the --sqlite flag, this tool will create an empty file and then fail with a confusing database error. Checking if the file exists using os.Stat before opening it prevents this silent file creation and provides a much clearer error message.

Suggested change
if *sqlitePath == "" {
return fmt.Errorf("--sqlite is required")
}
if *sqlitePath == "" {
return fmt.Errorf("--sqlite is required")
}
if _, err := os.Stat(*sqlitePath); os.IsNotExist(err) {
return fmt.Errorf("sqlite database file does not exist: %s", *sqlitePath)
}

@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 5m 28s —— View job


Review: accounting — fill client fee ledger gaps

I read the full PR diff (the 11 commits in accounting-ledger-reporting, +2687/-426) with focus on the ledger double-entry math, the new TLV message, the unroll exit-cost path, and the accounting tool. Overall this is a careful, well-documented change with strong test coverage and thoughtful idempotency/atomicity handling. Findings below, including an assessment of the three existing Gemini comments.


Assessment of the existing Gemini review

1. Operator fee should add intents.Leaves (round/transitions.go:3525) — ❌ already handled; the suggestion would introduce a bug.

The leaves loop is still present in computeClientOperatorFee at round/transitions.go:3527-3532:

for i := range intents.Leaves {
    amt := intents.LeaveAmount(i)
    if amt > 0 {
        outputsSat += amt
    }
}

Gemini's diff was reading the first hunk only; the leaves subtraction sits just past it and is unchanged from origin. Leaves (and foreign recipient VTXOs, now summed via intents.VTXOs) are already subtracted from the operator fee. Applying the suggestion would double-subtract leaves and understate the fee. The unmodified TestComputeClientOperatorFeeLeave (forfeit 60_000, leave 59_400, expects fee = 600) still pins this. No change needed.

2. ListClientAccountBalances OR join → correlated subqueries (fee_accounting.sql:310) — 🟡 valid optimization, low priority here.

The point is technically correct: LEFT JOIN ... ON debit_account = a.account_id OR credit_account = a.account_id defeats index usage. But this query backs an offline admin reporting tool reading a client daemon's SQLite DB, not a hot path, and the correlated-subquery form is also arguably cleaner (and avoids the GROUP BY). Worth doing as polish, not blocking. Fix this →

3. os.Stat before opening SQLite (main.go:144) — ❌ already handled.

openReadOnlySQLite (internal/cmd/tools/accounting/main.go:198) already does os.Stat, rejects directories, opens with mode=ro, and sets PRAGMA query_only = ON. A missing path now errors with stat sqlite db: ... rather than silently creating a file. No change needed.


My own findings

Strengths

  • Atomic clearing legs. Folding the sweep into one BoardingSweepConfirmedMsg and expanding all legs inside a single Commit (ledger/handlers.go:boardingSweepLegs) is the right call — wallet_clearing nets to zero or nothing is written. Per-leg idempotency keys match the historical single-message keys, so an in-flight message straddling an upgrade dedups cleanly.
  • No idempotency-key collisions across legs. Fee leg = raw 32-byte txid; input/return legs = walletUTXOIdempotencyKey (36 bytes); external transfer = "wallet-sweep:"+txid. Distinct lengths/prefixes, so the global idx_client_ledger_idempotent_key unique index can't suppress a sibling leg.
  • Clearing identity is producer-guaranteed. boardingSweepLedgerChainCost derives ChainCostSat = TotalAmount − destination, so Σ(inputs) − ChainCost − Destination == 0 holds by construction (given TotalAmount == Σ(input amounts)), and the anchor value is intentionally rolled into onchain_fees.
  • Unroll deferral. emitExitCostIfCompleted returning false to keep the child alive and retry on the next height tick — rather than losing a failed Tell right before the registry stops the child — is a genuinely subtle correctness fix, and WithoutCancel+WithoutTx correctly decouples the durable emission from request cancellation.
  • TLV codec for the new message is solid: fixed-width decodeSweepInputs with a modulo check, decodeAmountSat narrowing on every amount, txid length check, registered in newLedgerCodec.

Minor suggestions (non-blocking)

  • Defensive clearing-balance check. handleBoardingSweepConfirmed validates each amount is positive but not the conservation identity Σ(inputs) == ChainCostSat + DestinationSat. The atomicity guarantee only prevents partial writes — it doesn't catch a producer that ships an internally inconsistent set, which would durably strand a non-zero wallet_clearing balance. The producer derives the values to balance today, but an explicit guard (dead-letter with ErrInvalidMessage) would make the "nets to zero" invariant self-enforcing rather than relying on the producer. Fix this →

  • Exit-cost assumes a single-input sweep. In exitCostMsg, exitCost = targetOutput.Value − Σ(sweepTx.TxOut). This is correct only when the target output is the sweep's sole input. If a fee-bump path ever adds a wallet input to the unroll sweep, ExitCostSat would be understated (extra input value isn't in targetOutput.Value). Worth a confirming comment that the unroll sweep is always single-input, or summing TxIn values if not.

  • decodeSweepInputs allocation bound. count := len(blob) / sweepInputRecordSize then make(..., count). This is bounded only by the durable mailbox max message size. Given that ceiling it's fine, but an explicit max-inputs cap (mirroring MaxQuoteEntriesPerClient in round) would be defense-in-depth against a corrupt envelope.


Verdict

Solid, mergeable work. None of the three Gemini comments require action (two are already implemented; one would regress the fee math). My suggestions are all optional hardening. The double-entry model, idempotency design, and atomicity guarantees are sound, and the documentation (ledger/CLAUDE.md, docs/fee_ledger.md, caller contract table) is excellent.

  • Read the PR diff against the merge base
  • Review round settlement / operator fee changes
  • Review ledger handlers & messages
  • Review unroll exit-cost emission
  • Review wallet sweep accounting
  • Review accounting tool + SQL queries
  • Assess existing Gemini review points
  • Post review feedback
    · branch accounting-ledger-reporting

@Roasbeef
Roasbeef marked this pull request as ready for review June 24, 2026 00:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c60ad8196

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread round/transitions.go
Comment on lines +3512 to +3516
if len(intents.VTXOs) > 0 {
for i := range intents.VTXOs {
amt := int64(intents.VTXOs[i].Amount)
if amt > 0 {
outputsSat += amt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use quoted VTXO amounts when computing fees

For normal boarding/refresh intents, intents.VTXOs[i].Amount is the pre-fee target amount the wallet registered, while the server-stamped residual is what ends up in the confirmed VTXO and is available via ownedVTXOs (and only leave quotes are copied back onto Intents). Because this branch ignores ownedVTXOs whenever any VTXO request exists, a 100k boarding or refresh that confirms with a 99.5k VTXO computes outputs as 100k and emits no FeePaidMsg, so fees_paid and vtxo_balance are wrong for the common fee-paying paths. Please sum actual/quoted local VTXO amounts and only add the non-owned request amounts needed for recipient outflows.

Useful? React with 👍 / 👎.

Roasbeef added 15 commits June 23, 2026 18:39
In this commit, we close the fee-ledger gaps around round outflows and
wallet sweep accounting.

Round settlement now emits explicit outflow rows for directed-send
recipients and leaves, and it records boarding fees the same way refresh
fees are recorded. Boarding sweep confirmation now moves inputs through
wallet_clearing, records chain cost including the P2A anchor, and
settles external sweep destinations with a wallet sweep transfer event.
In this commit, we wire the unroll registry and child actor to the
ledger sink and emit ExitCostMsg once the final sweep confirms.

The event uses the proof target output as the gross exited value and the
persisted sweep transaction outputs to derive miner cost. This keeps
VTXO's expiry handoff from emitting zero-fee poison messages while still
recording confirmed unilateral exits.
In this commit, we add an internal accounting report tool backed by the
local ledger database.

The command reads sqlc-backed report queries directly, can render text
or JSON, and has an optional CoinGecko price source for fiat balance
checks without taking a Faraday dependency.
In this commit, we update the package maps and fee-ledger guide for the
new accounting flows.

The docs now point unilateral exit costs at unroll, describe
wallet_clearing and sweep transfers, and keep the accounting command
discoverable from the internal tools package.
In this commit, we make the accounting report command refuse to create
or mutate the target SQLite database.

The command now checks that the path exists, opens SQLite with mode=ro,
enables query_only on a single connection, and has regression tests for
missing paths and rejected writes.
Round outflow ledger rows use idempotency keys because directed-send
recipient outputs and cooperative leave outputs do not have local VTXO
outpoints.

The old keys only included the outflow kind and index. Since the DB
dedupes idempotency keys globally, later rounds could silently skip
transfers_out rows.

Prefix those keys with the round ID and add a regression test covering
foreign VTXO and leave outflows. Refresh the stale accounting comments
while here.
Update stale test and sweep comments to match wallet clearing.
External sweep destinations now settle with WalletSweepTransferMsg.
Boarding fees are covered by the fee handler tests.
On-chain sweep fee rows do not carry a round or session id. Require
their sweep txid idempotency key before inserting, so malformed
messages cannot bypass every ledger dedup index.

Refresh the directed-send fee test to cover the current split between
recipient outflow value and operator fee value.
The admin accounting tool's CoinGecko price source piped the HTTP
response body straight into json.NewDecoder().Decode with no size
bound, so a spoofed, MITM'd, or misbehaving endpoint could stream an
unbounded body and exhaust memory within the request timeout window.

Wrap the body in an io.LimitReader capped at 1 MiB (the simple-price
payload is a few hundred bytes) before decoding, and reject NaN/Inf
prices alongside the existing non-positive check so a poisoned value
cannot flow into the satoshi-to-fiat conversion.
Boarding-sweep confirmation emitted its accounting as a fan-out of
independent Tells: one FeePaidMsg{FeeTypeOnchainSweep}, one UTXOSpentMsg
per input, and a UTXOCreatedMsg or WalletSweepTransferMsg for the
destination. Each Tell landed in the ledger actor separately, so a
partial failure (one rejected or dropped leg) could commit some legs and
strand the rest, leaving the wallet_clearing account drifted non-zero
with no path back to zero.

The ledger is a durable actor, so the fix is not an outbox but a single
Tell. Add BoardingSweepConfirmedMsg carrying the sweep txid, chain cost
(miner fee + P2A anchor), the per-input list, and the destination
(external flag + amount). handleBoardingSweepConfirmed expands it into
every clearing leg inside ONE Commit, so wallet_clearing either nets to
zero or nothing is written. The per-leg idempotency keys match the old
single-message keys, so an in-flight message straddling an upgrade still
dedups.

This also closes two related gaps: the emitter now skips a record whose
tx is absent (it could not produce a balanced set, and the old
FeeAmount fallback dropped the anchor), and the audit-only non-positive
guard in handleUTXOSpent is scoped to the boarding-sweep-input branch so
a zero-amount audit spend can no longer poison the durable mailbox.

WalletSweepTransferMsg is removed (folded in). The FeeTypeOnchainSweep
and boarding-sweep classification handlers are retained for direct
callers and unit tests.
The confirmed unilateral exit cost is emitted to the ledger from
notifyRegistryIfTerminal, just before the registry is told the child is
terminal. That handoff stops the child, and completed records are not
restored on boot, so a failed exit-cost Tell had no later retry: the
emission was best-effort and the leg was lost.

The ledger sink is a durable mailbox, so a Tell that returns nil is
durably accepted and processed at-least-once. The only loss is a Tell
that errors (e.g. mailbox backpressure). Make emitExitCostIfCompleted
report whether the terminal handoff may proceed: a transient Tell
failure defers it, so the child stays alive and subscribed and the next
height tick retries; the ledger handler dedups by target outpoint, so a
redelivery is a no-op. A deterministic build failure (a should-never-
happen inconsistency on a completed actor) logs at error and proceeds so
the handoff is not wedged forever. VTXO retirement has its own
startup-reconciliation backstop, so deferring it is safe.

Also document that the recorded exit cost is the final sweep
transaction's miner fee only, not the cumulative cost of broadcasting
the intermediate tree transactions, which remains a deferred item.
While this work was in review, 000019_oor_session_registry merged to
main. CI rebases each PR onto main before testing, so the rebased tree
carried two migrations numbered 000019 (oor_session_registry and
accounting_wallet_sweeps); golang-migrate rejects the duplicate version
with "duplicate migration file", which aborted schema setup and failed
nearly every DB-backed test across the unit and system suites.

Renumber the accounting migration to 000020 so it lands after
000019_oor_session_registry, and bump LatestMigrationVersion to 20. The
migration SQL is unchanged, so make sqlc regenerates an identical
schema. Verified the migration tests pass both in isolation and with
000019_oor_session_registry present (the rebased layout).
Update the wallet -> ledger emission note to reflect the single
BoardingSweepConfirmedMsg that replaced the FeePaidMsg + UTXOSpentMsg +
UTXOCreatedMsg/WalletSweepTransferMsg fan-out.
The accounting report command hardcoded a read-only SQLite open, so it
could not run against a daemon backed by Postgres. Open the database
through the shared db package instead (db.NewStoreFromConfig), selecting
the backend exactly as the daemon does, and read inside a read-only
transaction (db.ReadTxOption) with SkipMigrations so the report never
mutates the schema. This mirrors the server-side accounting command.

Add a -backend selector plus per-backend connection flags, and emit the
report as CSV in addition to text and JSON. The sqlite backend still
refuses to create a fresh database on a missing path.

Note: the read-only transaction is enforced by Postgres, but the modernc
sqlite driver does not reject writes in a read-only transaction, so on
sqlite the read-only guarantee rests on SkipMigrations plus the report
issuing only SELECTs.
Document how to run the accounting report command against a SQLite or
Postgres daemon, the available flags, the text/JSON/CSV output
formats, optional CoinGecko fiat conversion, and the read-only
behavior (including the modernc sqlite caveat). Register the page
under Operations in the docs index and cross-link it from the command
package docs.
@Roasbeef
Roasbeef force-pushed the accounting-ledger-reporting branch from 22381f0 to 2cb10c2 Compare June 24, 2026 01:41
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 5m 52s —— View job


Re-review: the new Codex P1 is correct — this is a real fee-accounting bug

I focused this pass on the one item that postdates my earlier review: Codex's P1 on computeClientOperatorFee (round/transitions.go:3760). After tracing the data flow end-to-end, I agree with Codex, and I'm reversing the "mergeable as-is" verdict from my previous review — the common fee-paying boarding/refresh paths compute the wrong operator fee.

The bug

computeClientOperatorFee sums the output side like this (round/transitions.go:3758-3773):

if len(intents.VTXOs) > 0 {
    for i := range intents.VTXOs {
        amt := int64(intents.VTXOs[i].Amount)   // intent target — pre-fee / zero
        if amt > 0 { outputsSat += amt }
    }
} else {
    for _, v := range ownedVTXOs {              // confirmed leaf — post-fee
        if v != nil { outputsSat += int64(v.Amount) }
    }
}

The else branch (confirmed ownedVTXOs) is the correct source. The if branch (intents.VTXOs[i].Amount) is the wrong one — and normal boarding and refresh rounds always take the if branch, because s.Intents.VTXOs is populated for them (buildVTXORequest / buildVTXORequestFromRefresh in round/actor.go).

What intents.VTXOs[i].Amount actually holds is settled by the code right next to it. buildClientVTXOs explicitly refuses to read it (round/transitions.go:3935-3945):

// The on-chain tx output is the source of truth for the VTXO amount —
// under #270 the server stamps the seal-time residual onto the
// VTXODescriptor before building the tree, so the leaf's non-anchor
// output carries the quoted value rather than the intent target (which
// is zero for change outputs). Reading req.Amount here would persist
// stale data.
leafAmount, err := leafNonAnchorAmount(leaf)

So the fee path consumes precisely the value the VTXO-build path discards as "stale data." Confirmed by the seal handshake: at QuoteAccepted only QuotedLeaveAmounts is written back to Intents (transitions.go:~1415); VTXO quotes are never copied onto intents.VTXOs[i].Amount (consistent with RefreshVTXORequest.OperatorFee being "advisory only — the FSM does NOT subtract it from Amount" in round/CLAUDE.md).

Impact (both directions, both wrong)

  • Fixed-target owned output (e.g. 100k boarding → 99.5k confirmed): if branch sums 100k, inputs are 100k, so fee = 0. The 500-sat fee the client actually paid is dropped — no FeePaidMsg emitted, total_fees_paid_sat understated.
  • Designated change output (intent target 0, server stamps the residual): if branch sums 0 for that output, so the residual never offsets inputs and the fee is massively overstated (the entire change value is mis-booked as fee).

Either way OperatorFeeSat on VTXOCreatedNotification (transitions.go:4096) is wrong for the most common rounds.

Why the unit tests didn't catch it

TestComputeClientOperatorFeePureBoarding / …PureRefresh (round/operator_fee_test.go:31,54) pass empty intents.VTXOs and supply amounts only via ownedVTXOs, so they exercise the else branch — the path production never takes for those rounds. TestComputeClientOperatorFeeDirectedSendWithChange (line 142) hand-feeds the change output its post-fee residual (59_500) as the intent amount, which the comment above tells us is 0 in production. The tests encode an assumption the producer contradicts.

Suggested fix

Use confirmed ownedVTXOs for locally-owned outputs, and add intents.VTXOs amounts only for the foreign directed-send recipient slots that aren't represented in ownedVTXOs (the server honors those verbatim). That's also what roundLedgerOutflows already isolates, so the fee and the outflow rows would draw the recipient set from one place. The unit tests for the boarding/refresh cases should be rebuilt to populate intents.VTXOs (as production does) so they'd actually fail today.

Fix this →


The rest of the diff (unchanged from my prior assessment)

  • Gemini lib: start adding lib helpers #1 (add intents.Leaves) — still a no-op / would regress. The leaves loop already exists at transitions.go:3775-3780; adding another would double-subtract. ❌
  • Gemini multI: add initial repo scaffolding #2 (OR join → correlated subqueries) — valid micro-optimization for an offline tool, non-blocking. 🟡
  • Gemini chainbackend+chainsource: add chainsource actor and backend impl #3 (os.Stat before SQLite open) — already handled by openReadOnlySQLite. ❌
  • Unroll exit-cost deferral, atomic clearing legs, idempotency-key disjointness, TLV codec — reviewed previously, all sound. The clearing-balance conservation guard and single-input exit-cost comment remain optional hardening.

Verdict

One blocking change: fix the operator-fee output source in computeClientOperatorFee (Codex P1). Everything else stands as before — the ledger model, atomicity, and idempotency design are solid, and the docs are excellent. My earlier "mergeable as-is" was wrong on this point; the Codex finding is correct and it affects the headline feature of this PR (recording the round fee flows).

  • Read PR diff vs origin/main
  • Investigate Codex P1 — confirmed real bug, traced producer (buildClientVTXOs), seal handshake, and tests
  • Re-check Gemini points & ledger handlers
  • Review unroll exit-cost path
  • Post review
    · branch accounting-ledger-reporting

@Roasbeef
Roasbeef merged commit 61a9ed8 into main Jun 24, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant