Skip to content

persist collected fees#933

Open
bitcoin-coder-bob wants to merge 17 commits intomasterfrom
bob/persist-collected-fees
Open

persist collected fees#933
bitcoin-coder-bob wants to merge 17 commits intomasterfrom
bob/persist-collected-fees

Conversation

@bitcoin-coder-bob
Copy link
Collaborator

@bitcoin-coder-bob bitcoin-coder-bob commented Feb 25, 2026

closes #859

  • Add GetCollectedFees admin endpoint (GET /v1/admin/fees/collected) that returns aggregate collected fees across batches, with optional time-range filtering (after/before unix timestamps)

  • a calculateCollectedFees function that correctly accounts for boarding input amounts (previously fees were accumulated per-intent without including boarding inputs, leading to incorrect values I believe)

Bug breakdown:

prior: we were adding the boding input amount for each intent.

	for _, intent := range round.Intents {
		a.CollectedFees += intent.TotalInputAmount() + a.BoardingInputAmount - intent.TotalOutputAmount()

now: we add the boardingInput amount one time

	totalIn := boardingInputAmount
	totalOut := uint64(0)
	for _, intent := range round.Intents {
		totalIn += intent.TotalInputAmount()
		totalOut += intent.TotalOutputAmount()
	}
  • fix boarding input detection: the BoardingInputAmount is now computed from the PSBT taproot leaf scripts rather than being passed incorrectly

  • GetCollectedFees function added to round repo. Queries db using SelectCollectedFees which is a new query, that runs on the round table.

func (r *Round) EndFinalization in internal/core/domain/round.go modified to take in a new param collectedFees uint64 and is added as a new field into the RoundFinalized struct.

The Round struct also has a new Collected Fees uint64 field which is leveraged in the GetCollectedFees functions.

e2e_test.go additions for hitting new admin endpoint.

alert_test.go additions for testing against calculateCollectedFees.

NOTE: All fees are expressed in sats.

I found I had to increase the timeout in the Makefile's integrationtest and needed to bump the otel package version due to a CVE in our version used causing CI to fail

Summary by CodeRabbit

  • New Features

    • Added admin API endpoint /v1/admin/fees/collected (GET) to query total collected fees within a time window via after/before query parameters.
  • Improvements

    • Persist collected fees for rounds so totals reflect actual data.
    • Improved handling of boarding and fee calculation to produce accurate totals.
  • Tests

    • Added unit and end-to-end tests covering collected-fees queries and calculation logic.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds persistent per-round collected fees: DB schema, repo, domain, application logic, gRPC admin endpoint to query fees by time range, and tests (unit, integration, e2e). Also extracts helpers to compute boarding input and collected fees and wires fees through finalization and events.

Changes

Cohort / File(s) Summary
API Spec & Protobuf
api-spec/openapi/swagger/ark/v1/admin.openapi.json, api-spec/protobuf/ark/v1/admin.proto
New GET /v1/admin/fees/collected endpoint with after/before parameters and request/response schemas; protobuf RPC GetCollectedFees and messages added.
Domain Model & Events
internal/core/domain/round.go, internal/core/domain/round_event.go, internal/core/domain/round_repo.go, internal/core/domain/round_test.go
Added CollectedFees to Round, extended EndFinalization signature to accept collected fees, included fees in RoundFinalized event, added RoundRepository.GetCollectedFees, and updated tests.
Application Logic
internal/core/application/admin.go, internal/core/application/alert.go, internal/core/application/service.go, internal/core/application/utils.go, internal/core/application/alert_test.go
Extracted calculateCollectedFees() and calculateBoardingInputAmount(), compute boarding & collected fees during finalization and pass into domain finalization; added admin service method to fetch fees; tests for calculation.
DB Migrations
internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql, ...down.sql, internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql, ...down.sql
Add non-null fees column (default 0, CHECK >= 0) to round table and down migrations to drop it.
Postgres infra & SQLC
internal/infrastructure/db/postgres/round_repo.go, internal/infrastructure/db/postgres/sqlc/.../models.go, .../query.sql.go, .../query.sql
Persist/read fees in upsert/select, added SelectCollectedFees SQL (SUM) and corresponding sqlc bindings; repository method GetCollectedFees.
SQLite infra & SQLC
internal/infrastructure/db/sqlite/round_repo.go, internal/infrastructure/db/sqlite/sqlc/.../models.go, .../query.sql.go, .../query.sql
Same as Postgres: persist/read fees, added SelectCollectedFees, overflow guard when converting to int64, repository GetCollectedFees.
Badger repo
internal/infrastructure/db/badger/ark_repo.go
Added GetCollectedFees by filtering ended/non-failed rounds by timestamp and summing CollectedFees; include CollectedFees in upsert payload.
gRPC Interface & Permissions
internal/interface/grpc/handlers/adminservice.go, internal/interface/grpc/permissions/permissions.go
Added gRPC handler GetCollectedFees with parameter validation and permission mapping entry.
Tests & E2E
internal/infrastructure/db/service_test.go, internal/test/e2e/e2e_test.go, internal/test/e2e/utils_test.go
New repo tests for GetCollectedFees ranges and failed-round exclusion; new e2e TestCollectedFees and helper to call admin endpoint; tightened churn test synchronization/timeouts.
Client & Tooling
pkg/client-lib/client/grpc/client.go, .github/workflows/unit.yaml, Makefile
Refactored stream send helper in gRPC client, added gosec exclusion G118, increased e2e integration timeout to 1200s.

Sequence Diagram(s)

sequenceDiagram
    participant Finalization as Finalizer
    participant Domain as Domain Model
    participant Repo as Repository
    participant DB as Database

    Finalization->>Finalization: calculateBoardingInputAmount(ptx)
    Finalization->>Finalization: calculateCollectedFees(round, boardingAmount)
    Finalization->>Domain: EndFinalization(forfeitTxs, txn, collectedFees)
    Domain->>Domain: Emit RoundFinalized event (Fees = collectedFees)
    Domain->>Repo: UpsertRound(round with CollectedFees)
    Repo->>DB: INSERT/UPDATE round.fees
    DB-->>Repo: OK
Loading
sequenceDiagram
    participant Client as gRPC Client
    participant Handler as Admin Handler
    participant Service as AdminService
    participant Repo as RoundRepository
    participant DB as Database

    Client->>Handler: GetCollectedFees(after, before)
    Handler->>Handler: validate after/before
    Handler->>Service: GetCollectedFees(ctx, after, before)
    Service->>Repo: GetCollectedFees(ctx, after, before)
    Repo->>DB: SELECT COALESCE(SUM(fees),0) WHERE ended=true AND failed=false AND timestamp BETWEEN after AND before
    DB-->>Repo: sum
    Repo-->>Service: uint64 total
    Service-->>Handler: uint64 total
    Handler-->>Client: GetCollectedFeesResponse{CollectedFees}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • altafan
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive Most changes align with issue #859. However, changes to .github/workflows/unit.yaml (G118 exclusion) and Makefile (timeout increase) appear tangential to collected fees persistence and lack clear justification in the PR context. Clarify whether gosec G118 exclusion and integration test timeout increase are necessary for this feature, or relocate them to a separate PR focused on CI/infra improvements.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'persist collected fees' directly describes the core change: implementing persistence for collected fees to the database and exposing them via an admin API.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #859: adds collected_fees column to rounds table, persists per-round fees via CollectedFees field and RoundFinalized event, exposes fees via GetCollectedFees admin endpoint with time-range filtering, and includes e2e testing of the new endpoint.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch bob/persist-collected-fees

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bitcoin-coder-bob bitcoin-coder-bob changed the title Bob/persist collected fees persist collected fees Feb 25, 2026
@bitcoin-coder-bob
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (3)
internal/core/domain/round_test.go (1)

475-488: Add one non-zero collected-fees assertion case.

These tests now compile with the new signature, but they don’t verify the new behavior. Add a case with non-zero fees and assert it is reflected in RoundFinalized and round state.

Suggested test enhancement
- events, err = round.EndFinalization(forfeitTxs, finalCommitmentTx, 0)
+ const collectedFees uint64 = 1234
+ events, err = round.EndFinalization(forfeitTxs, finalCommitmentTx, collectedFees)
  require.NoError(t, err)
  ...
  event, ok := events[0].(domain.RoundFinalized)
  require.True(t, ok)
+ require.Equal(t, collectedFees, event.CollectedFees)
+ require.Equal(t, collectedFees, round.CollectedFees)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/domain/round_test.go` around lines 475 - 488, Add a new subtest
that calls round.EndFinalization with a non-zero collected fees value and assert
the value appears on the emitted domain.RoundFinalized event and on the round
state; specifically, invoke round.EndFinalization(forfeitTxs, finalCommitmentTx,
nonZeroFees) in a separate test case, require no error, cast events[0] to
domain.RoundFinalized and assert event.CollectedFees equals nonZeroFees and
event.Timestamp equals round.EndingTimestamp, and also assert
round.CollectedFees (or the corresponding round field) reflects nonZeroFees
along with the existing IsStarted/IsEnded/IsFailed checks so the new signature
behavior is verified.
internal/infrastructure/db/service_test.go (1)

711-733: Add a regression case for ended=true + failed=true rounds.

Current failure fixture is never finalized, so it does not catch the case where a round is finalized and then marked failed. That edge case should stay excluded from collected-fee totals.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/service_test.go` around lines 711 - 733, Create a
regression test that covers the “ended=true and failed=true” edge case by
constructing a round that is first finalized and then marked failed (e.g., build
a domain.NewRoundFromEvents sequence containing domain.RoundStarted,
domain.RoundFinalized with Timestamp X, then domain.RoundFailed with Timestamp >
X), set failedRound.CollectedFees to a non-zero value and call
repo.AddOrUpdateRound(ctx, *failedRound), and then assert this round is excluded
from collected-fee totals; update the existing fixture that currently only
includes an unfinished failed round (the failedRound variable and its
AddOrUpdateRound call) to use this finalized-then-failed event sequence so the
test verifies rounds with ended=true && failed=true are ignored.
internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go (1)

2185-2198: The cast is explicit but unguarded. domain.Round.CollectedFees (uint64) is converted at internal/infrastructure/db/sqlite/round_repo.go:94 and internal/infrastructure/db/postgres/round_repo.go:94 using direct casts (int64(round.CollectedFees)) with no bounds validation. While the maximum Bitcoin supply (~2.1×10^15 satoshis) falls well within math.MaxInt64 (~9.2×10^18), adding explicit bounds checking would strengthen the implementation as defensive programming against future changes or unexpected values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go` around lines
2185 - 2198, domain.Round.CollectedFees (uint64) is being directly cast to int64
when populating UpsertRoundParams.CollectedFees in round_repo.go (see the cast
at internal/infrastructure/db/sqlite/round_repo.go:94 and
internal/infrastructure/db/postgres/round_repo.go:94); add an explicit bounds
check against math.MaxInt64 before casting and handle the out-of-range case
(e.g., return an error from the repository function or otherwise surface/log the
condition) instead of unguarded casting so you never overflow the int64 field in
UpsertRoundParams; reference domain.Round.CollectedFees and
UpsertRoundParams.CollectedFees when implementing the check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/application/alert.go`:
- Around line 121-125: In calculateCollectedFees, don't add boardingInputAmount
per intent and avoid uint64 underflow/overflow: move adding boardingInputAmount
outside the loop (add it once to the total), and inside the loop compute
per-intent fee as max(0, intent.TotalInputAmount() - intent.TotalOutputAmount())
to prevent unsigned wrap; when accumulating, guard additions against uint64
overflow (e.g., check before adding or use math/bits.Add64) so collectedFees
never wraps. Ensure you reference calculateCollectedFees, round.Intents,
intent.TotalInputAmount() and intent.TotalOutputAmount() when making the
changes.

In `@internal/infrastructure/db/badger/ark_repo.go`:
- Around line 130-133: The current query always applies StartingTimestamp >
after (via badgerhold.Where(...).And("StartingTimestamp").Gt(after)), but per
API contract passing after=0 should be treated as “unset”; update the code that
builds the BadgerHold query (the query variable in ark_repo.go where
badgerhold.Where("Stage.Ended") is used) to only append the
.And("StartingTimestamp").Gt(after) clause when after > 0 (similar to the
existing before > 0 check), so that after==0 does not filter by
StartingTimestamp.
- Around line 127-144: GetCollectedFees sums Round.CollectedFees but
addOrUpdateRound currently omits CollectedFees when building the rnd value
stored in the DB, so reloaded rounds have zeroed fees; update addOrUpdateRound
to preserve/persist CollectedFees (copy round.CollectedFees into rnd before the
upsert or ensure the persistence mapping includes the CollectedFees field) so
that GetCollectedFees reads the correct persisted value, and keep the rest of
the upsert logic in addOrUpdateRound unchanged.

In
`@internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql`:
- Line 1: Update the migration to enforce a non-negative invariant on the new
column by adding a CHECK constraint for collected_fees; keep the existing NOT
NULL DEFAULT 0 but append a CHECK (collected_fees >= 0) or add an explicit
constraint (e.g., ADD CONSTRAINT round_collected_fees_nonnegative CHECK
(collected_fees >= 0)) to the ALTER TABLE statement that adds collected_fees so
the database rejects negative values at the boundary.

In `@internal/infrastructure/db/postgres/round_repo.go`:
- Line 94: The CollectedFees fields are being cast between uint64 and int64
without bounds checks (e.g., round.CollectedFees → CollectedFees int64 and DB
int64 → uint64), which can overflow/underflow; before casting in the
marshal/save path (where round.CollectedFees is converted to int64) ensure value
≤ math.MaxInt64 and return an error if larger, and in the unmarshal/load path
(where DB CollectedFees int64 is converted to uint64) ensure the int64 is
non-negative before converting and return/report an error if negative. Update
all occurrences referenced (the current cast at round.CollectedFees and the
similar conversions around the other ranges mentioned) to perform these explicit
checks and propagate an error instead of silently wrapping.

In `@internal/infrastructure/db/postgres/sqlc/query.sql`:
- Around line 443-445: The aggregation query currently filters only on ended =
true so it still includes rounds that were later marked failed; update the WHERE
clause in the SQL (the block referencing ended, starting_timestamp, `@after` and
`@before`) to explicitly exclude failed rounds by adding a condition such as AND
failed = FALSE (or AND NOT failed / AND is_failed = FALSE depending on the
actual column name) so only non-failed, ended rounds are included in the revenue
totals.

In
`@internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql`:
- Line 1: The new column on table "round" (collected_fees) must forbid negative
values at the schema level: update the migration so the ADD COLUMN defines
collected_fees as INTEGER NOT NULL DEFAULT 0 with a CHECK constraint enforcing
collected_fees >= 0; if the SQLite version in use does not support adding a
CHECK via ALTER TABLE, implement the safe table-recreate pattern (create new
table with the CHECK, copy rows, drop old table, rename) to ensure the
non-negative invariant is applied.

In `@internal/infrastructure/db/sqlite/round_repo.go`:
- Line 94: The code silently coerces round.CollectedFees between uint64 and
int64 (the CollectedFees assignment and corresponding db read code), risking
overflow/wrap and a default branch that returns 0, nil for unexpected types;
change the write path to validate that round.CollectedFees fits into int64
before casting and return an explicit error if it does not, and change the
read/unmarshal path (the switch/scan handling the CollectedFees column) to
explicitly handle expected types (int64, int32, uint64, string numeric) with
range checks converting to uint64, and remove the catch-all default that returns
0, nil—instead return a descriptive error for unexpected types or out-of-range
values; apply the same hardening to the other occurrences referenced around the
CollectedFees handling (the other two blocks noted).

In `@internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go`:
- Around line 368-373: The SQLite query returns interface{} because the SUM
result isn't explicitly cast; update the SQLite SQL for the queries referenced
by SelectCollectedFees and SelectExpiringLiquidityAmount to cast the aggregated
COALESCE(SUM(...), 0) to an integer (e.g., CAST(COALESCE(SUM(collected_fees), 0)
AS INTEGER)) so SQLC will infer int64, then regenerate SQLC artifacts; target
the SQL string/constants that define selectCollectedFees and the equivalent
selectExpiringLiquidityAmount so their generated Go functions
(SelectCollectedFees, SelectExpiringLiquidityAmount) return int64 instead of
interface{}.

In `@internal/infrastructure/db/sqlite/sqlc/query.sql`:
- Around line 442-447: The query SelectCollectedFees currently always applies
starting_timestamp > sqlc.arg('after'), so after=0 is not treated as "no lower
bound"; update the WHERE clause to short-circuit when after <= 0 by adding a
condition like (sqlc.arg('after') <= 0 OR starting_timestamp >
sqlc.arg('after')) alongside the existing before check so that passing after=0
disables the lower-bound filter.

---

Nitpick comments:
In `@internal/core/domain/round_test.go`:
- Around line 475-488: Add a new subtest that calls round.EndFinalization with a
non-zero collected fees value and assert the value appears on the emitted
domain.RoundFinalized event and on the round state; specifically, invoke
round.EndFinalization(forfeitTxs, finalCommitmentTx, nonZeroFees) in a separate
test case, require no error, cast events[0] to domain.RoundFinalized and assert
event.CollectedFees equals nonZeroFees and event.Timestamp equals
round.EndingTimestamp, and also assert round.CollectedFees (or the corresponding
round field) reflects nonZeroFees along with the existing
IsStarted/IsEnded/IsFailed checks so the new signature behavior is verified.

In `@internal/infrastructure/db/service_test.go`:
- Around line 711-733: Create a regression test that covers the “ended=true and
failed=true” edge case by constructing a round that is first finalized and then
marked failed (e.g., build a domain.NewRoundFromEvents sequence containing
domain.RoundStarted, domain.RoundFinalized with Timestamp X, then
domain.RoundFailed with Timestamp > X), set failedRound.CollectedFees to a
non-zero value and call repo.AddOrUpdateRound(ctx, *failedRound), and then
assert this round is excluded from collected-fee totals; update the existing
fixture that currently only includes an unfinished failed round (the failedRound
variable and its AddOrUpdateRound call) to use this finalized-then-failed event
sequence so the test verifies rounds with ended=true && failed=true are ignored.

In `@internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go`:
- Around line 2185-2198: domain.Round.CollectedFees (uint64) is being directly
cast to int64 when populating UpsertRoundParams.CollectedFees in round_repo.go
(see the cast at internal/infrastructure/db/sqlite/round_repo.go:94 and
internal/infrastructure/db/postgres/round_repo.go:94); add an explicit bounds
check against math.MaxInt64 before casting and handle the out-of-range case
(e.g., return an error from the repository function or otherwise surface/log the
condition) instead of unguarded casting so you never overflow the int64 field in
UpsertRoundParams; reference domain.Round.CollectedFees and
UpsertRoundParams.CollectedFees when implementing the check.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0e584c0 and 63ccae1.

⛔ Files ignored due to path filters (4)
  • api-spec/protobuf/gen/ark/v1/admin.pb.go is excluded by !**/*.pb.go, !**/gen/**
  • api-spec/protobuf/gen/ark/v1/admin.pb.rgw.go is excluded by !**/gen/**
  • api-spec/protobuf/gen/ark/v1/admin_grpc.pb.go is excluded by !**/*.pb.go, !**/gen/**
  • api-spec/protobuf/gen/ark/v1/indexer.pb.rgw.go is excluded by !**/gen/**
📒 Files selected for processing (25)
  • api-spec/openapi/swagger/ark/v1/admin.openapi.json
  • api-spec/protobuf/ark/v1/admin.proto
  • internal/core/application/admin.go
  • internal/core/application/alert.go
  • internal/core/application/service.go
  • internal/core/domain/round.go
  • internal/core/domain/round_event.go
  • internal/core/domain/round_repo.go
  • internal/core/domain/round_test.go
  • internal/infrastructure/db/badger/ark_repo.go
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.down.sql
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/postgres/round_repo.go
  • internal/infrastructure/db/postgres/sqlc/queries/models.go
  • internal/infrastructure/db/postgres/sqlc/queries/query.sql.go
  • internal/infrastructure/db/postgres/sqlc/query.sql
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.down.sql
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/sqlite/round_repo.go
  • internal/infrastructure/db/sqlite/sqlc/queries/models.go
  • internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
  • internal/infrastructure/db/sqlite/sqlc/query.sql
  • internal/interface/grpc/handlers/adminservice.go
  • internal/interface/grpc/permissions/permissions.go

@bitcoin-coder-bob
Copy link
Collaborator Author

@arkanaai review

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review February 28, 2026 18:28
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/infrastructure/db/sqlite/round_repo.go (1)

574-574: ⚠️ Potential issue | 🟠 Major

Guard negative DB values before int64 -> uint64 conversion in rowsToRounds.

At Line 574, uint64(v.round.CollectedFees) can wrap negative DB values into very large positive numbers, silently corrupting round data downstream.

🛠️ Suggested fix
@@
 		round, ok = rounds[v.round.ID]
 		if !ok {
+			if v.round.CollectedFees < 0 {
+				return nil, fmt.Errorf(
+					"data integrity issue: negative collected_fees %d for round %s",
+					v.round.CollectedFees, v.round.ID,
+				)
+			}
 			round = &domain.Round{
@@
-				CollectedFees:      uint64(v.round.CollectedFees),
+				CollectedFees:      uint64(v.round.CollectedFees),
 				FailReason:         v.round.FailReason.String,
 			}
 		}

Use this read-only script to verify schema constraints and remaining conversion sites:

#!/bin/bash
set -euo pipefail

echo "== collected_fees in SQL schema/migrations =="
fd -e sql | while read -r f; do
  rg -n "collected_fees|CREATE TABLE[[:space:]]+round|CHECK[[:space:]]*\\(" "$f" -C1 || true
done

echo
echo "== CollectedFees conversions in sqlite round repo =="
rg -n "int64\\(round\\.CollectedFees\\)|uint64\\(v\\.round\\.CollectedFees\\)|CollectedFees:" internal/infrastructure/db/sqlite/round_repo.go -C2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/sqlite/round_repo.go` at line 574, In
rowsToRounds, guard against negative DB values before converting int64 -> uint64
for CollectedFees: check v.round.CollectedFees for < 0 and handle it (reject the
row or return an error) instead of blindly doing uint64(v.round.CollectedFees);
update the conversion site (uint64(v.round.CollectedFees)) to perform the check
and either return a descriptive error from rowsToRounds or clamp to 0 if that
policy is acceptable, and add a test exercising negative CollectedFees to ensure
the new behavior is enforced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/client-lib/client/grpc/client.go`:
- Around line 271-278: The send closure currently drops events when ctx.Done()
is closed which can lose terminal errors; change send (and its callers using
eventsCh) to ensure best-effort delivery of terminal Err events by: when
ctx.Done() is selected, if ev represents a terminal/error event, block (or retry
with a short timeout/backoff) until eventsCh accepts it instead of returning
false; for non-terminal events you may keep the non-blocking behavior. Update
the send closure and the call sites that close the stream/channel to use this
ensured-delivery behavior so terminal client.BatchEvent (Err) cannot be lost,
referencing the send function, eventsCh, ctx.Done() and the terminal/error
BatchEvent type.

---

Duplicate comments:
In `@internal/infrastructure/db/sqlite/round_repo.go`:
- Line 574: In rowsToRounds, guard against negative DB values before converting
int64 -> uint64 for CollectedFees: check v.round.CollectedFees for < 0 and
handle it (reject the row or return an error) instead of blindly doing
uint64(v.round.CollectedFees); update the conversion site
(uint64(v.round.CollectedFees)) to perform the check and either return a
descriptive error from rowsToRounds or clamp to 0 if that policy is acceptable,
and add a test exercising negative CollectedFees to ensure the new behavior is
enforced.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 63ccae1 and 8fa3c3c.

⛔ Files ignored due to path filters (5)
  • api-spec/protobuf/gen/ark/v1/admin.pb.go is excluded by !**/*.pb.go, !**/gen/**
  • go.sum is excluded by !**/*.sum
  • pkg/arkd-wallet/go.sum is excluded by !**/*.sum
  • pkg/kvdb/go.sum is excluded by !**/*.sum
  • pkg/macaroons/go.sum is excluded by !**/*.sum
📒 Files selected for processing (26)
  • .github/workflows/unit.yaml
  • Makefile
  • api-spec/openapi/swagger/ark/v1/admin.openapi.json
  • api-spec/protobuf/ark/v1/admin.proto
  • go.mod
  • internal/core/application/admin.go
  • internal/core/application/alert.go
  • internal/core/application/alert_test.go
  • internal/core/application/service.go
  • internal/core/domain/round_test.go
  • internal/infrastructure/db/badger/ark_repo.go
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/postgres/round_repo.go
  • internal/infrastructure/db/postgres/sqlc/queries/query.sql.go
  • internal/infrastructure/db/postgres/sqlc/query.sql
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/sqlite/round_repo.go
  • internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
  • internal/infrastructure/db/sqlite/sqlc/query.sql
  • internal/test/e2e/e2e_test.go
  • internal/test/e2e/utils_test.go
  • pkg/arkd-wallet/go.mod
  • pkg/client-lib/client/grpc/client.go
  • pkg/kvdb/go.mod
  • pkg/macaroons/go.mod
🚧 Files skipped from review as they are similar to previous changes (7)
  • internal/core/application/service.go
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/postgres/round_repo.go
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql
  • api-spec/openapi/swagger/ark/v1/admin.openapi.json
  • internal/core/application/alert.go

@bitcoin-coder-bob bitcoin-coder-bob force-pushed the bob/persist-collected-fees branch from 23bcb22 to bd4b66a Compare March 3, 2026 16:22
uses: securego/gosec@master
with:
args: '-severity high -quiet -exclude=G115 ./...'
args: '-severity high -quiet -exclude=G115,G118 ./...'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
args: '-severity high -quiet -exclude=G115,G118 ./...'
args: '-severity high -quiet -exclude=G115 ./...'

isn't fixed by master ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

did something change on master? I was having tests timeout without this

get: "/v1/admin/liquidity/recoverable"
};
}
rpc GetCollectedFees(GetCollectedFeesRequest) returns (GetCollectedFeesResponse) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

not sure we need this cause it's subset of GetRounds ? we can sum the fees client side maybe ? I'd remove it to simplify except if it's a feature request from @Kukks / @altafan

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

was asked in the issue: Expose via admin gRPC API for revenue tracking

Comment on lines +142 to +144
if len(input.TaprootLeafScript) > 0 {
boardingInputAmount += uint64(input.WitnessUtxo.Value)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if len(input.TaprootLeafScript) > 0 {
boardingInputAmount += uint64(input.WitnessUtxo.Value)
}
// TODO fragile, it may fail if arkd-wallet uses TaprootLeafScript in the future
if len(input.TaprootLeafScript) > 0 {
boardingInputAmount += uint64(input.WitnessUtxo.Value)
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

that's something we do everywhere, it's ok now but let's add TODO so we don't forget it's not ideal if arkd-wallet becomes a tapscript wallet

}

// calculateCollectedFees computes the total fees (sats) collected by the coordinator for a given round.
func calculateCollectedFees(round *domain.Round, boardingInputAmount uint64) uint64 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

this file is more for ports.Alerts methods, let's move the fee funcs to utils.go ?

RoundEvent
ForfeitTxs []ForfeitTx
FinalCommitmentTx string
CollectedFees uint64
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
CollectedFees uint64
Fees uint64

nit: Fees are always "collected" from arkd point of view no ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

only argument against that is to distinguish it from on chain fees?

@@ -0,0 +1 @@
ALTER TABLE round ADD COLUMN collected_fees BIGINT NOT NULL DEFAULT 0 CHECK (collected_fees >= 0);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
ALTER TABLE round ADD COLUMN collected_fees BIGINT NOT NULL DEFAULT 0 CHECK (collected_fees >= 0);
ALTER TABLE round ADD COLUMN fees BIGINT NOT NULL DEFAULT 0 CHECK (collected_fees >= 0);

nit

Version uint
Swept bool
VtxoTreeExpiration int64
CollectedFees uint64
Copy link
Collaborator

Choose a reason for hiding this comment

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

should be big.Int to handle overflow

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

given bitcoins supply cap is this even a concern? I think adding this type adds complexity for all the places we then end up having to use it.

Comment on lines +4230 to +4246
faucetOnchain(t, aliceBoardingAddr, 0.001)
time.Sleep(6 * time.Second)

wg := &sync.WaitGroup{}
wg.Add(2)

var incomingErr error
go func() {
_, incomingErr = alice.NotifyIncomingFunds(ctx, aliceOffchainAddr)
wg.Done()
}()

var settleErr error
go func() {
_, settleErr = alice.Settle(ctx)
wg.Done()
}()
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's add another participant to that batch, doing a renewal (not boarding). to verify the fees equal the sum and check offchain settlement are also included

defer close(eventsCh)
backoffDelay := utils.GrpcReconnectConfig.InitialDelay

send := func(ev client.BatchEventChannel) bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we open a dedicated PR for this ? it sounds like an enhancement

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
internal/infrastructure/db/postgres/round_repo.go (1)

508-508: ⚠️ Potential issue | 🟠 Major

Guard row-level fee conversion before casting to uint64.

uint64(v.round.Fees) can silently wrap if a negative value is ever present in storage. Return an error instead of coercing.

🛠️ Suggested fix
 func rowsToRounds(rows []combinedRow) ([]*domain.Round, error) {
 	rounds := make(map[string]*domain.Round)

 	for _, v := range rows {
 		var round *domain.Round
 		var ok bool

 		round, ok = rounds[v.round.ID]
 		if !ok {
+			if v.round.Fees < 0 {
+				return nil, fmt.Errorf(
+					"data integrity issue: negative collected_fees %d for round %s",
+					v.round.Fees, v.round.ID,
+				)
+			}
 			round = &domain.Round{
 				Id:                v.round.ID,
 				StartingTimestamp: v.round.StartingTimestamp,
 				EndingTimestamp:   v.round.EndingTimestamp,
@@
-				CollectedFees:      uint64(v.round.Fees),
+				CollectedFees:      uint64(v.round.Fees),
 				FailReason:         v.round.FailReason.String,
 			}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/postgres/round_repo.go` at line 508, The code
currently casts v.round.Fees to uint64 for CollectedFees which will silently
wrap on negative values; update the mapping where CollectedFees is set
(reference v.round.Fees and CollectedFees) to first check if v.round.Fees < 0
and if so return an error (with a clear message like "negative fee value in DB")
instead of coercing, otherwise safely cast to uint64; ensure the function that
performs this mapping returns the error up the call chain so negative stored
fees are surfaced rather than wrapped.
internal/infrastructure/db/sqlite/round_repo.go (1)

574-574: ⚠️ Potential issue | 🟠 Major

Protect row mapping from signed→unsigned wrap.

uint64(v.round.Fees) can produce corrupted large values if v.round.Fees is negative. Fail fast with an explicit error.

🛠️ Suggested fix
 func rowsToRounds(rows []combinedRow) ([]*domain.Round, error) {
 	rounds := make(map[string]*domain.Round)

 	for _, v := range rows {
 		var round *domain.Round
 		var ok bool

 		round, ok = rounds[v.round.ID]
 		if !ok {
+			if v.round.Fees < 0 {
+				return nil, fmt.Errorf(
+					"data integrity issue: negative collected_fees %d for round %s",
+					v.round.Fees, v.round.ID,
+				)
+			}
 			round = &domain.Round{
 				Id:                v.round.ID,
@@
-				CollectedFees:      uint64(v.round.Fees),
+				CollectedFees:      uint64(v.round.Fees),
 				FailReason:         v.round.FailReason.String,
 			}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/infrastructure/db/sqlite/round_repo.go` at line 574, The assignment
CollectedFees: uint64(v.round.Fees) can wrap if v.round.Fees is negative; update
the mapping (the code that sets CollectedFees from v.round.Fees) to check if
v.round.Fees < 0 and return a clear error (or propagate one) before converting
to uint64, e.g., validate v.round.Fees and fail fast with an explicit error
message referencing the offending round ID or context instead of silently
casting.
🧹 Nitpick comments (1)
internal/core/application/utils.go (1)

593-596: Log the anomalous totalOut > totalIn clamp path.

Right now negative-fee scenarios are silently converted to zero. Emitting a warning only for totalOut > totalIn would make accounting anomalies diagnosable without changing behavior.

♻️ Suggested tweak
-	if totalOut >= totalIn {
+	if totalOut > totalIn {
+		log.WithFields(log.Fields{
+			"round_id":  round.Id,
+			"total_in":  totalIn,
+			"total_out": totalOut,
+		}).Warn("calculated negative collected fees; clamping to zero")
+		return 0
+	}
+	if totalOut == totalIn {
 		return 0
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/utils.go` around lines 593 - 596, Inside the
function that currently clamps negative fees using the totalOut and totalIn
variables, add a warning log when the anomalous path totalOut > totalIn is
taken: keep the existing behavior of returning 0 when totalOut >= totalIn, but
before returning, if totalOut > totalIn emit a warning including the totalOut
and totalIn values (using the module's logger/Logger instance) so the anomaly is
recorded, then return 0; otherwise return totalIn - totalOut as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/test/e2e/e2e_test.go`:
- Around line 4818-4828: The test currently treats select timeouts on wgDone and
sentinelDone as mere logs which allows leaked goroutines to go unnoticed; change
both timeout branches to fail the test (e.g., call t.Fatalf or t.Fatalf-like
helper) so the test stops and reports a failure when the wait exceeds the
timeout, and apply the same change to the other occurrences mentioned (the block
around wgDone/sentinelDone at lines ~5185-5195) so all timeout paths fail rather
than only logging.

---

Duplicate comments:
In `@internal/infrastructure/db/postgres/round_repo.go`:
- Line 508: The code currently casts v.round.Fees to uint64 for CollectedFees
which will silently wrap on negative values; update the mapping where
CollectedFees is set (reference v.round.Fees and CollectedFees) to first check
if v.round.Fees < 0 and if so return an error (with a clear message like
"negative fee value in DB") instead of coercing, otherwise safely cast to
uint64; ensure the function that performs this mapping returns the error up the
call chain so negative stored fees are surfaced rather than wrapped.

In `@internal/infrastructure/db/sqlite/round_repo.go`:
- Line 574: The assignment CollectedFees: uint64(v.round.Fees) can wrap if
v.round.Fees is negative; update the mapping (the code that sets CollectedFees
from v.round.Fees) to check if v.round.Fees < 0 and return a clear error (or
propagate one) before converting to uint64, e.g., validate v.round.Fees and fail
fast with an explicit error message referencing the offending round ID or
context instead of silently casting.

---

Nitpick comments:
In `@internal/core/application/utils.go`:
- Around line 593-596: Inside the function that currently clamps negative fees
using the totalOut and totalIn variables, add a warning log when the anomalous
path totalOut > totalIn is taken: keep the existing behavior of returning 0 when
totalOut >= totalIn, but before returning, if totalOut > totalIn emit a warning
including the totalOut and totalIn values (using the module's logger/Logger
instance) so the anomaly is recorded, then return 0; otherwise return totalIn -
totalOut as before.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bd4b66a and 71df2ab.

📒 Files selected for processing (19)
  • internal/core/application/alert.go
  • internal/core/application/utils.go
  • internal/core/domain/round.go
  • internal/core/domain/round_event.go
  • internal/core/domain/round_test.go
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.down.sql
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/postgres/round_repo.go
  • internal/infrastructure/db/postgres/sqlc/queries/models.go
  • internal/infrastructure/db/postgres/sqlc/queries/query.sql.go
  • internal/infrastructure/db/postgres/sqlc/query.sql
  • internal/infrastructure/db/service_test.go
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.down.sql
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql
  • internal/infrastructure/db/sqlite/round_repo.go
  • internal/infrastructure/db/sqlite/sqlc/queries/models.go
  • internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go
  • internal/infrastructure/db/sqlite/sqlc/query.sql
  • internal/test/e2e/e2e_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.down.sql
  • internal/infrastructure/db/postgres/sqlc/queries/models.go
  • internal/core/domain/round_event.go
  • internal/infrastructure/db/postgres/migration/20260225000000_add_collected_fees.up.sql
  • internal/core/domain/round_test.go
  • internal/infrastructure/db/sqlite/migration/20260225000000_add_collected_fees.up.sql

Comment on lines +4818 to +4828
select {
case <-wgDone:
case <-time.After(10 * time.Second):
t.Log("churn/producer goroutines did not exit within 10s")
}

select {
case <-sentinelDone:
case <-time.After(5 * time.Second):
t.Log("sentinel goroutine did not exit within 5s")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Timeout paths should fail, not only log.

When wait timeouts trigger, the test currently continues and can pass with leaked goroutines, masking real shutdown regressions.

🛠️ Suggested fix
-	case <-time.After(10 * time.Second):
-		t.Log("churn/producer goroutines did not exit within 10s")
+	case <-time.After(10 * time.Second):
+		t.Fatalf("churn/producer goroutines did not exit within 10s")
@@
-	case <-time.After(5 * time.Second):
-		t.Log("sentinel goroutine did not exit within 5s")
+	case <-time.After(5 * time.Second):
+		t.Fatalf("sentinel goroutine did not exit within 5s")

Also applies to: 5185-5195

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/test/e2e/e2e_test.go` around lines 4818 - 4828, The test currently
treats select timeouts on wgDone and sentinelDone as mere logs which allows
leaked goroutines to go unnoticed; change both timeout branches to fail the test
(e.g., call t.Fatalf or t.Fatalf-like helper) so the test stops and reports a
failure when the wait exceeds the timeout, and apply the same change to the
other occurrences mentioned (the block around wgDone/sentinelDone at lines
~5185-5195) so all timeout paths fail rather than only logging.

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.

Persist collected fees

2 participants