Sign forfeit txs at collection-time - #1110
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds operator-signature backfilling for historical forfeit PSBTs. A new ChangesForfeit TX Backfill Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
ghost
left a comment
There was a problem hiding this comment.
🔍 Arkana Protocol Review — PR #1110
Verdict: Request changes (protocol-critical — requires human sign-off)
This PR changes the forfeit transaction signing path, which is the mechanism for punishing fraud. The overall design is sound: eager signing at collection time makes forfeits broadcast-ready at rest, eliminating signer availability as a single point of failure at fraud-reaction time. The backward-compatible approach (fraud.go re-sign is harmless due to XOnlyPubKey keying in script/finalizer.go:41) is well-reasoned.
Test coverage is solid — unit tests for signForfeitTxs, backfill core logic (signs unsigned, skips already-signed, idempotent re-runs, signer failure counting), and repo-level PatchForfeitTxs tests on sqlite+postgres. Good.
No cross-repo breakage: RoundRepository is arkd-internal, no SDK or consumer repo implements or imports it. ForfeitTx references in emulator and introspector-review are test-only.
Issues
1. 🔴 UpdateForfeitTx silently succeeds on non-existent txid (all SQL backends)
internal/infrastructure/db/postgres/sqlc/query.sql:414 and sqlite/sqlc/query.sql:422
UPDATE tx SET tx = @tx WHERE txid = @txid AND type = 'forfeit';This is :exec — ExecContext returns nil error even when 0 rows are affected. If a txid doesn't match (e.g., data corruption, a race where the round was deleted between scan and patch), the backfill silently counts it as signed (res.Signed++ at backfill.go:131) but nothing was persisted.
The PatchCollectedFees precedent has the same gap, but for forfeit txs this is protocol-critical: an operator who runs the backfill and sees signed=50 failed=0 would believe their forfeits are broadcast-ready when they aren't.
Fix: Either change the sqlc annotation to :execresult and check RowsAffected() == 0, or add a verification read-back. The badger implementation is fine (Upsert always writes).
2. 🟡 GetAllVtxos loads entire vtxo set into memory (backfill.go:61)
allVtxos, err := vtxos.GetAllVtxos(ctx)A production operator may have millions of vtxos. This loads all of them into memory just to filter down to unswept forfeited ones. For an offline tool this isn't a blocker, but it should be documented (or a filtered query added).
Suggestion: Add a comment noting the memory implications, or consider adding a GetForfeitableVtxos query that filters at the DB level.
3. 🟡 SQLite concurrent access with a running arkd (cmd/arkd-forfeit-backfill/main.go)
The backfill tool opens the same SQLite database file as a running arkd process. SQLite is opened without WAL mode (internal/infrastructure/db/sqlite/utils.go), and the retry budget is only 5 × 100ms = 500ms. If arkd is finalizing a round while the backfill writes, PatchForfeitTxs will hit "database is locked" errors.
This is safe (idempotent re-run), but operators should be warned. The tool's doc comment and README should state: "For SQLite deployments, stop arkd or ensure low write activity before running."
4. 🟡 No ctx.Done() check in backfill loops (backfill.go:77-132)
The Run function iterates all commitment groups and all vtxos within each group without checking context cancellation. For a large backfill (thousands of forfeits), ctrl+C won't interrupt cleanly — the signer call will eventually timeout, but the loop won't break.
for commitmentTxid, group := range byCommitment {
// Should check: if ctx.Err() != nil { return res, ctx.Err() }5. ℹ️ Minor: findForfeitTx is duplicated (backfill.go:145 vs fraud.go)
The PR description notes this is intentional to avoid a dependency on the application package. That's a reasonable tradeoff, but the two implementations should stay in sync. Consider adding a comment in both locations referencing each other.
What looks correct
-
signForfeitTxs(service.go:2055-2075): Correctly signs each forfeit tx viaSignTransactionTapscriptwithnilinput indexes (matching the fraud.go pattern), parses the signed PSBT to extract the unchanged txid, and wraps it asdomain.ForfeitTx. Error on any single forfeit correctly fails the entire round — right call for collection-time signing. -
fraud.goleft unchanged: The broadcast-time re-sign is harmless becausescript/finalizer.go:41keys witness args byXOnlyPubKey, so a duplicate operator signature is a no-op. Verified in source. -
Backfill idempotency:
forfeitOperatorSignedchecks for existing tapscript sigs matching the operator'sXOnlyPubKeybefore calling the signer. Re-runs are safe. ThefakeRounds.PatchForfeitTxstest double correctly applies patches to stored rounds so re-run assertions work. -
DB implementations: Postgres and SQLite use
execTxfor transactional writes. Badger uses directUpserton theTxrecord (correct — forfeits are stored as standaloneTxentries, not embedded in the round document). -
Build scripts:
build-arkd-forfeit-backfillfollows the existingbuild-arkdpattern.
Protocol-critical flag 🚨
This PR modifies the forfeit signing path — the operator's ability to punish fraud. Even though the code looks correct, a human must review and approve per protocol rules. Key points for human reviewer:
- Verify that
SignTransactionTapscript(ctx, tx, nil)at collection time signs the same inputs it would at broadcast time infraud.go:167. - Verify that the backfill's
forfeitOperatorSignedcheck correctly identifies the operator's signature (not a user signature that happens to be tapscript). - Confirm the silent-UPDATE issue (#1 above) is acceptable or should be fixed before merge.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
internal/core/application/service_forfeit_test.go (1)
90-98: ⚡ Quick winAdd a malformed-signer-output subtest for parse-failure coverage.
The helper has a dedicated parse-error path for signer output, but this test file currently only covers signer-call error. Adding one malformed-PSBT case will lock in that branch behavior.
Proposed test addition
t.Run("returns error when the signer fails", func(t *testing.T) { signer := &fakeForfeitSigner{err: errors.New("signer unavailable")} s := &service{signer: signer} _, err := s.signForfeitTxs(ctx, []string{userSigned}) require.Error(t, err) }) + + t.Run("returns error when signer returns malformed psbt", func(t *testing.T) { + signer := &fakeForfeitSigner{returnTx: "not-a-psbt"} + s := &service{signer: signer} + + _, err := s.signForfeitTxs(ctx, []string{userSigned}) + + require.Error(t, err) + }) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/core/application/service_forfeit_test.go` around lines 90 - 98, Add a new subtest within the same test function after the "returns error when the signer fails" test case to cover the parse-failure path. Create a fakeForfeitSigner that returns malformed PSBT output (not an error itself, but data that fails to parse), then call s.signForfeitTxs with a userSigned input and verify it returns an error due to the parse failure. This ensures the parse-error handling logic in signForfeitTxs is exercised and locked in.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/backfill/backfill.go`:
- Around line 38-41: The SignTransactionTapscript method signature in the Signer
interface exceeds the golines line length limit on line 40. Split the method
signature across multiple lines by breaking the parameters and return types onto
separate lines to comply with the formatting requirements enforced by golines.
In `@internal/core/application/service.go`:
- Around line 2059-2077: In the signForfeitTxs method, you need to verify that
the unsigned transaction body remains unchanged after signing to prevent
persisting invalid txid↔tx mappings. Parse the original unsignedTx before
calling SignTransactionTapscript to extract and save its txid, then after
parsing the signed transaction result with psbt.NewFromRawBytes, verify that the
unsigned tx body's txid matches the original. If the txids do not match, return
an error indicating that the signer modified the unsigned transaction body,
which would break forfeit lookups and reactions. This check ensures the signer
only adds witness data and does not alter the transaction structure itself.
In `@internal/infrastructure/db/badger/ark_repo.go`:
- Around line 105-114: The PatchForfeitTxs method currently blindly calls Upsert
for any txid without validating that the record exists, which can create new
records instead of only patching existing forfeit txs. Additionally, it ignores
the transaction context from ctx. Fix this by: (1) validating that each txid
exists in the store before attempting to patch (read the record first to confirm
it exists), and (2) using TxUpsert instead of Upsert when ctx.Value("tx") is
present to respect tx-bound atomicity, similar to the SQL backend's UPDATE WHERE
type='forfeit' semantics. Only proceed with the patch operation if the record
pre-exists.
In `@internal/infrastructure/db/postgres/round_repo.go`:
- Around line 502-517: The PatchForfeitTxs method currently only checks for
query errors but does not validate that each UpdateForfeitTx call actually
affected a row in the database, allowing silent failures when a txid does not
match any forfeit row. Modify the UpdateForfeitTx query to return the number of
affected rows using the :execrows pattern, then in the PatchForfeitTxs method
add a check after each update call to ensure exactly one row was affected
(rowsAffected == 1); if not, return an error indicating the specific txid was
not found or did not match any row.
In `@internal/infrastructure/db/sqlite/round_repo.go`:
- Around line 567-582: The PatchForfeitTxs method in roundRepository does not
validate that the database update actually modified any rows, so an unmatched
txid silently succeeds instead of failing. Replace the call to UpdateForfeitTx
(which only returns an error) with an update method that returns the number of
affected rows, then check if the affected rows count is zero and return an error
with a descriptive message (e.g., "txid not found") when no rows were updated.
Keep the existing error handling for actual database errors.
---
Nitpick comments:
In `@internal/core/application/service_forfeit_test.go`:
- Around line 90-98: Add a new subtest within the same test function after the
"returns error when the signer fails" test case to cover the parse-failure path.
Create a fakeForfeitSigner that returns malformed PSBT output (not an error
itself, but data that fails to parse), then call s.signForfeitTxs with a
userSigned input and verify it returns an error due to the parse failure. This
ensures the parse-error handling logic in signForfeitTxs is exercised and locked
in.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 38126088-0b2a-47f3-8291-cdd640a6cfbf
📒 Files selected for processing (17)
cmd/arkd-forfeit-backfill/main.gointernal/backfill/backfill.gointernal/backfill/backfill_test.gointernal/config/config.gointernal/core/application/service.gointernal/core/application/service_forfeit_test.gointernal/core/domain/round_repo.gointernal/infrastructure/db/badger/ark_repo.gointernal/infrastructure/db/postgres/round_repo.gointernal/infrastructure/db/postgres/sqlc/queries/query.sql.gointernal/infrastructure/db/postgres/sqlc/query.sqlinternal/infrastructure/db/service_test.gointernal/infrastructure/db/sqlite/round_repo.gointernal/infrastructure/db/sqlite/sqlc/queries/query.sql.gointernal/infrastructure/db/sqlite/sqlc/query.sqlscripts/build-allscripts/build-arkd-forfeit-backfill
ghost
left a comment
There was a problem hiding this comment.
🔍 Arkana — Incremental Review (new commit a71e6d5)
Commit: backfill: wrap long signatures to satisfy golines
Purely cosmetic — line wrapping on function signatures and log statements in internal/backfill/backfill.go. No logic changes. LGTM on this commit.
Previous findings still outstanding — my earlier review's issues (#1 silent UPDATE on non-existent txid, #2 memory load, #3 SQLite concurrency warning, #4 missing ctx.Done check) remain unaddressed. The CHANGES_REQUESTED state stands until at least #1 is resolved or explicitly acknowledged as acceptable risk.
…key PSBT Forfeit txs are now operator-signed at collection time. Re-signing them at fraud-broadcast time appended a second identical operator signature, producing an invalid PSBT (duplicate key) that failed to finalize. Only sign when the operator signature is still missing (legacy forfeits).
ghost
left a comment
There was a problem hiding this comment.
🔍 Arkana — Incremental Review (new commit dcbc0ce)
Commit: fraud: skip re-signing already-signed forfeit txs to avoid duplicate-key PSBT
Good catch. This fixes a real bug introduced by the collection-time signing in this same PR: broadcastForfeitTx unconditionally called SignTransactionTapscript, which for already-signed forfeits would append a duplicate operator signature → invalid PSBT → failed finalization → forfeit can't broadcast → fraud goes unpunished. That's a critical correctness issue and this commit correctly addresses it.
Review of the fix:
forfeitTxOperatorSigned(fraud.go:433-444) — logic is correct: scans all inputs'TaprootScriptSpendSigfor the operator's x-only pubkey. This matches the check inbackfill.go:forfeitOperatorSigned(identical logic, different function name). ✅- The
GetPubkeycall atfraud.go:176adds one signer RPC per fraud reaction. Fraud is rare; negligible overhead. ✅ - Conditional signing at
fraud.go:179-183— correctly preserves the original code path for legacy unsigned forfeits while skipping for newly pre-signed ones. ✅ - Test coverage (
service_forfeit_test.go:99-131) — covers signed, unsigned, and wrong-key cases. ✅
One minor note: forfeitTxOperatorSigned (fraud.go) and forfeitOperatorSigned (backfill.go) are identical logic with slightly different names. Consider extracting to a shared internal util to avoid drift, but this is non-blocking.
Previous findings still outstanding — issues #1 (silent UPDATE on non-existent txid), #2 (memory load), #3 (SQLite concurrency warning), #4 (missing ctx.Done check) from my initial review remain unaddressed. The CHANGES_REQUESTED state stands until at least #1 is resolved or explicitly acknowledged.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/core/application/fraud.go`:
- Around line 174-178: The unconditional call to s.signer.GetPubkey(ctx) in the
fraud handling logic causes the entire flow to fail when the signer is
unavailable, even if the forfeit transaction is already signed and
broadcast-ready. Instead of always calling the signer, retrieve the operator
pubkey from local state or configuration, or alternatively, first attempt to
verify the stored forfeit transaction using the pubkey without touching the
signer, only invoking s.signer.GetPubkey(ctx) when a signature is actually
missing. Additionally, add a regression test to verify that fraud handling
succeeds when the forfeit PSBT is already signed but the signer is unavailable,
ensuring the change achieves its objective of enabling persisted forfeits to be
broadcast-ready without signer availability.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f5d600cf-4aaf-45b7-b2ae-53ee1d159a2e
📒 Files selected for processing (2)
internal/core/application/fraud.gointernal/core/application/service_forfeit_test.go
| // sign here when the operator signature is still missing (e.g. forfeit txs | ||
| // collected before collection-time signing was introduced). | ||
| signedForfeitTx := forfeitTxB64 | ||
| signerPubkey, err := s.signer.GetPubkey(ctx) |
There was a problem hiding this comment.
this does a grpc call, but the goal of the PR was "if the operator's signer were unavailable when fraud must be punished, the forfeit could not be broadcast." so if the signer is down, this will error and we will not broadcast. We need to pull the pubkey from a cache perhaps. So if signer is down when we need to broadcast a forfeit, we will fail here which directly goes against stated goal. Caching the pubkey and reading it from the cache here should fix this.
There was a problem hiding this comment.
lgtm other than need a fixing of merge conflict and the one comment: https://github.com/arkade-os/arkd/pull/1110/changes#r3415820827
# Conflicts: # internal/infrastructure/db/postgres/sqlc/query.sql
ghost
left a comment
There was a problem hiding this comment.
🔍 Arkana — Incremental Review (merge commit 97d5541)
Scope: Single merge commit bringing origin/master into presign-forfeit-txs.
Conflict resolution in internal/infrastructure/db/postgres/sqlc/query.sql: ✅ Clean. The branch's UpdateForfeitTx query and master's SelectAssetsWithUnspentAmountsByIds query occupy distinct positions — both are preserved, ordering is correct.
No PR code was modified — the 18 changed files and +897/-10 delta are identical to my previous review. No new findings.
Previous review stance (request-changes for human sign-off on protocol-critical forfeit signing) still applies.
broadcastForfeitTx now reads the operator key set (current + deprecated) from the cached settings instead of calling the live signer, so a forfeit pre-signed at collection time stays broadcastable when the signer is unavailable, which is the point of signing at collection time. Deprecated keys are included so a forfeit signed before a signer-key rotation is recognized as already signed and not re-signed with the current key (which would not satisfy its old-key tapscript). The backfill tool applies the same current+deprecated check.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/backfill/backfill.go (1)
172-206: 💤 Low valueConsider extracting shared helpers to avoid drift.
findForfeitTxandforfeitOperatorSignedduplicate the implementations infraud.go. The comment explains this avoids a dependency on the application package, which is reasonable. However, if either implementation evolves, they could drift apart.A lightweight internal utility package (e.g.,
internal/forfeit) could house these helpers without pulling in application-layer dependencies, keeping both call sites in sync.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/backfill/backfill.go` around lines 172 - 206, Extract the duplicated `findForfeitTx` and `forfeitOperatorSigned` functions from both the backfill and fraud packages into a new lightweight internal utility package (such as `internal/forfeit`) to maintain a single source of truth and prevent implementation drift. Create this new package, move both functions there, and then update the import statements in `internal/backfill/backfill.go` and `internal/core/application/fraud.go` to reference the shared implementations from the new package, removing the duplicate code from both locations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/backfill/backfill.go`:
- Around line 172-206: Extract the duplicated `findForfeitTx` and
`forfeitOperatorSigned` functions from both the backfill and fraud packages into
a new lightweight internal utility package (such as `internal/forfeit`) to
maintain a single source of truth and prevent implementation drift. Create this
new package, move both functions there, and then update the import statements in
`internal/backfill/backfill.go` and `internal/core/application/fraud.go` to
reference the shared implementations from the new package, removing the
duplicate code from both locations.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ac37f384-13a1-4883-b34e-e928ef8c0c81
📒 Files selected for processing (4)
internal/backfill/backfill.gointernal/backfill/backfill_test.gointernal/core/application/fraud.gointernal/core/application/service_forfeit_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
- internal/core/application/service_forfeit_test.go
- internal/backfill/backfill_test.go
ghost
left a comment
There was a problem hiding this comment.
Code Review
Verdict: APPROVED — This is a sound, well-structured fix to a real liveness risk in forfeit broadcasting. The core logic is correct, the duplicate-signature regression is cleanly handled, and the test coverage (unit, repo, e2e) is solid. Flagging two medium issues and some nits for the team.
MEDIUM
M1: Silent no-op on txid miss in UpdateForfeitTx (Postgres + SQLite)
UPDATE tx SET tx = $1 WHERE txid = $2 AND type = 'forfeit' returns no error if zero rows are matched. If a txid is stale or wrong, PatchForfeitTxs succeeds, the backfill increments res.Signed, and no one knows the row was never written.
Suggested fix: check RowsAffected() after ExecContext and surface a warning or error on 0.
result, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("forfeit tx %s not found in db", arg.Txid)
}M2: forfeitOperatorSigned cross-input false-positive risk
Both implementations iterate over all inputs looking for the operator's x-only key in any TaprootScriptSpendSig. A forfeit tx has exactly two inputs: the vtxo (index 0, operator-signed) and the connector (index 1). If a future format change moves the vtxo input, or if the connector tapscript happens to reference the operator's key (e.g. via key reuse in the connector branch), the check could return true for an actually-unsigned forfeit and skip re-signing, causing a failed broadcast.
Tightening to check only ptx.Inputs[0].TaprootScriptSpendSig would be strictly correct for the current forfeit design and would self-document the assumption.
LOW / Nits
- Badger
PatchForfeitTxsusesUpsert, notUpdate: SQL backends guard withAND type='forfeit'; badger doesn't distinguish by type in the key. Cosmetically inconsistent; practically safe since Bitcoin txids are globally unique. GetAllVtxosfull table scan: acceptable for a one-shot backfill, but operators with a large vtxo set may see high memory usage. A targeted filtered query would be cleaner long-term.- No timeout on
context.Background()inmain.go: a stuck signer gRPC call hangs the tool forever. Considersignal.NotifyContextor a deadline. forfeitTxOperatorSignedis duplicated:fraud.goandbackfill.gohave identical implementations. The local copy is justified to avoid the application import cycle. Fine for now; consider a sharedinternal/forfeitpackage if a third callsite appears.- Unquoted path vars in
build-arkd-forfeit-backfill:pushd $PARENT_PATHwill break for paths with spaces. Cosmetic but inconsistent with shell best practices. - Test assertion reuse of
f3:test_patch_forfeit_txsuses thef3constant both as patched content for txida and as the expected-unchanged value of f3Tx, which is confusing on first read.
This touches forfeit broadcasting and round finalization — protocol-critical paths. The logic is correct and the e2e coverage via TestReactToFraud provides a strong functional gate. The medium issues are safety-net improvements, not correctness blockers for this PR. Human sign-off recommended before merge per policy.
ghost
left a comment
There was a problem hiding this comment.
Code Review
Verdict: APPROVED — This is a sound, well-structured fix to a real liveness risk in forfeit broadcasting. The core logic is correct, the duplicate-signature regression is cleanly handled, and the test coverage (unit, repo, e2e) is solid. Flagging two medium issues and some nits for the team.
MEDIUM
M1: Silent no-op on txid miss in UpdateForfeitTx (Postgres + SQLite)
UPDATE tx SET tx = $1 WHERE txid = $2 AND type = 'forfeit' returns no error if zero rows are matched. If a txid is stale or wrong, PatchForfeitTxs succeeds, the backfill increments res.Signed, and no one knows the row was never written.
Suggested fix: check RowsAffected() after ExecContext and surface a warning or error on 0.
result, err := q.db.ExecContext(ctx, updateForfeitTx, arg.Tx, arg.Txid)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("forfeit tx %s not found in db", arg.Txid)
}M2: forfeitOperatorSigned cross-input false-positive risk
Both implementations iterate over all inputs looking for the operator's x-only key in any TaprootScriptSpendSig. A forfeit tx has exactly two inputs: the vtxo (index 0, operator-signed) and the connector (index 1). If a future format change moves the vtxo input, or if the connector tapscript happens to reference the operator's key, the check could return true for an actually-unsigned forfeit and skip re-signing, causing a failed broadcast.
Tightening to check only ptx.Inputs[0].TaprootScriptSpendSig would be strictly correct for the current forfeit design and self-document the assumption.
LOW / Nits
- Badger
PatchForfeitTxsusesUpsert, notUpdate: SQL backends guard withAND type='forfeit'; badger doesn't distinguish by type in the key. Cosmetically inconsistent; practically safe since Bitcoin txids are globally unique. GetAllVtxosfull table scan: acceptable for a one-shot backfill, but operators with a large vtxo set may see high memory usage. A targeted filtered query would be cleaner long-term.- No timeout on
context.Background()inmain.go: a stuck signer gRPC call hangs the tool forever. Considersignal.NotifyContextor a deadline. forfeitTxOperatorSignedis duplicated:fraud.goandbackfill.gohave identical implementations. The local copy is justified to avoid the application import cycle. Fine for now; consider a sharedinternal/forfeitpackage if a third callsite appears.- Unquoted path vars in
build-arkd-forfeit-backfill:pushd $PARENT_PATHwill break for paths with spaces. - Test assertion reuse of
f3:test_patch_forfeit_txsuses thef3constant both as patched content for txida and as the expected-unchanged value of f3Tx, which is confusing on first read.
This touches forfeit broadcasting and round finalization — protocol-critical paths. The logic is correct and the e2e coverage via TestReactToFraud provides a strong functional gate. The medium issues are safety-net improvements, not correctness blockers for this PR. Human sign-off recommended before merge per policy.
UpdateForfeitTx silently no-op'd on a txid miss (sqlite/postgres) or inserted a stray record (badger), so the forfeit backfill could report a tx as signed when nothing was written. - sqlite/postgres: UpdateForfeitTx -> :execrows; PatchForfeitTxs errors when zero rows match. - badger: Upsert -> Update so a missing txid returns ErrNotFound instead of inserting; mapped to the same not-found error. - tests: assert the not-found path on the sqlite/postgres harness and add a focused badger PatchForfeitTxs test.
ghost
left a comment
There was a problem hiding this comment.
🔍 Arkana — Incremental Review (commits 16830b5, 06aa0d8)
Verdict: APPROVED — Both new commits are clean, well-tested fixes that directly address issues from the previous review.
Commit 16830b5f — fraud: broadcast pre-signed forfeits without the live signer
This is the right fix. Reading the operator key set from the cached settings instead of the live signer eliminates the dependency on signer availability at fraud-broadcast time — which was the entire motivation for pre-signing at collection time.
What I checked:
fraud.go:179-194: Settings cache is populated at service init (service.go:128), sosettingsis non-nil on the fraud path. The nil guard at:183is correct defensive code.fraud.go:186-194: Deprecated key inclusion prevents re-signing a forfeit whose tapscript references an old (rotated-out) key, which would produce an invalid witness. Logic mirrors the backfill path.backfill.go:71-83: Same deprecated-key pattern, sourced from the signer (correct for a standalone tool that doesn't have the settings cache).forfeitTxOperatorSignedsignature updated consistently in bothfraud.goandbackfill.go— the duplication is justified to avoid an import cycle.- Test coverage:
TestBackfillSkipsForfeitsSignedWithDeprecatedKeyand the updatedTestForfeitTxOperatorSigned(multi-key set assertion at:127) both cover the new path.
Edge case verified: If operatorKeys is empty (settings.SignerPubkey nil, no deprecated keys), forfeitTxOperatorSigned returns false, and the code falls through to the live signer. This is correct — it degrades to the legacy path rather than silently skipping signing.
No issues found.
Commit 06aa0d8e — fix(db): fail loudly when PatchForfeitTxs targets a missing txid
This directly addresses my M1 finding from the first review. Clean implementation across all three backends:
- sqlite/postgres (
sqlite/round_repo.go:634-643,postgres/round_repo.go:507-516)::exec→:execrows, checkRowsAffected() == 0. Correct. - badger (
badger/ark_repo.go:109-115):Upsert→Update, mappingErrNotFoundto a consistent error message. Correct — prevents inserting a stray record. - Tests:
service_test.go:768-769asserts the not-found path on sqlite/postgres; newbadger/ark_repo_test.gocovers badger specifically. Both verify the stray-record scenario doesn't occur.
No issues found.
Both previous MEDIUM findings are now resolved. The PR is in good shape. Protocol-critical paths (forfeit broadcast, round finalization) — human sign-off still recommended before merge.
Co-Reviewed-By: Arkana 🔍
Dunsin-cyber
left a comment
There was a problem hiding this comment.
utACK 06aa0d8, read through the forfeit signing path, logic looks sound.
Regression test for collection-time forfeit signing: boards/settles a vtxo under the old key, settles again to forfeit it (pre-signed at collection), hard-rotates the signer with no deprecated key, then fraudulently unrolls the forfeited vtxo and asserts the server still broadcasts the pre-signed forfeit and claims it.
fb44215 to
f38c739
Compare
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
This touches the fraud/forfeit path — please get an eyes-on protocol review from the arkd core team before merging. My comments below are read-only findings, not an approval.
Correctness / security
1. forfeitTxOperatorSigned trusts presence-of-key, not signature validity (potential griefing amplification)
internal/core/application/fraud.go:684-695 (and the twin at internal/backfill/backfill.go:261-272) treats a forfeit as operator-signed the moment any TaprootScriptSpendSig on any input carries an x-only pubkey matching the operator (or a deprecated) key — the signature bytes are never verified.
VerifyForfeitTxs in internal/infrastructure/tx-builder/covenantless/builder.go:281 only asserts len(TaprootScriptSpendSig) > 0 on the vtxo input; it does not restrict which x-only keys may appear, nor bound the count. A malicious submitter can therefore attach a bogus TaprootScriptSpendSig{XOnlyPubKey: operatorXOnly, LeafHash: <correct>, Signature: <garbage 64B>} alongside the user's real sig.
Before this PR, arkd re-signed at fraud time regardless, so both the bogus and a fresh valid operator sig would be present in the PSBT and the finalizer had a chance of picking the good one. After this PR, broadcastForfeitTx sees the poisoned entry, forfeitTxOperatorSigned returns true, we skip signing, and finalization runs with only the user's sig + the poisoned "operator" sig — producing an unfinalizable / invalid tx and preventing the operator from ever punishing that fraud. This turns a mostly-latent hole into a reliable per-vtxo DoS.
Fixes to consider: (a) reject at collection time any TaprootScriptSpendSig on the vtxo input whose x-only key isn't in the vtxo's authorized cosigner set; (b) make forfeitTxOperatorSigned verify the signature (schnorr + correct sighash + matching leaf hash), not just the key; or (c) at collection time, strip any pre-existing operator-key sigs on the vtxo input before calling signForfeitTxs.
2. TestEagerForfeitSurvivesWalletRotation doesn't verify what it claims (internal/test/e2e/e2e_test.go:6759-…)
The test's comment says "pre-signed forfeit survives a hard signer rotation". Trace what actually runs:
- After
recreateArkdWallet(newSignerKey, "")and restart,settings.SignerPubkey= new key,DeprecatedSignerPubkeys= empty (asserted at line ~1286). - The forfeit was signed at collection time with the OLD key.
forfeitTxOperatorSigned(forfeitTx, [newXOnly])therefore returns false. - We fall into the "operator sig missing" branch and call
s.signer.SignTransactionTapscript(...)with the current (new) key.signerKeyForLeaf(pkg/arkd-wallet/core/application/wallet/service.go:790) falls back tow.SignerKeybecause deprecated keys are empty, appends a sig that does NOT verify against the leaf's committed old key, and the resulting PSBT contains both the old (valid) and the new (invalid-for-leaf)TaprootScriptSpendSig. - Broadcast only succeeds because
FinalizeAndExtracthappens to pick the correct old-key sig.
So the test does not exercise the "eager signing" branch at all after a hard rotation, and it silently depends on live-signer availability + the finalizer's sig-selection heuristic. The claim "forfeit signed at collection time must stay broadcastable across a hard signer rotation" is stronger than what's actually being tested. Either (a) retain the old key as deprecated in the rotation so forfeitTxOperatorSigned returns true and the pre-signed path is actually exercised, or (b) additionally stop/kill the signer service before the fraud step so the test would fail if the code silently fell back to live signing.
Related: the doc comment in fraud.go:636-645 sells "pre-signed forfeit must stay broadcastable even when the signer is down". That's only true when the signing key that produced the forfeit is still in settings.SignerPubkey ∪ DeprecatedSignerPubkeys. Under a hard rotation, the benefit is lost. Worth calling out in the comment.
3. Collection-time signing also fixes a key-spend sig on the connector
s.signer.SignTransactionTapscript(ctx, tx, nil) in signForfeitTxs (internal/core/application/service.go:2716-2733) does more than the docstring implies: passing nil inputIndexes causes the arkd-wallet signer to also produce a TaprootKeySpendSig on the connector input (see pkg/arkd-wallet/core/application/wallet/service.go:699-724). That's likely fine — connectors are wallet-derived P2TR — but it means the forfeit's connector key-spend sig is now baked in at collection time. Two consequences worth verifying with the wallet-key rotation story: (a) if the connector-account xpriv is rotated (e.g. wallet re-seed), previously-stored forfeit txs become unbroadcastable even though the vtxo tapscript sig would still verify; (b) at broadcast time we now rely on the pre-set key-spend sig staying valid — SignTransactionTapscript short-circuits when len(TaprootKeySpendSig) > 0, so a user who pre-plants a bogus TaprootKeySpendSig on the connector would poison the forfeit (pre-existing but now more relevant). Both deserve at least a docstring note; ideally VerifyForfeitTxs would strip/reject a pre-set TaprootKeySpendSig on the connector input.
4. Backfill Failed counter over-counts on SQL rollback
internal/backfill/backfill.go:225-232: on rounds.PatchForfeitTxs(ctx, patch) error, res.Failed += len(patch). Postgres/sqlite implementations run all patches in a single execTx, so a single "forfeit tx %s not found" rolls back the whole batch and this is correct in terms of state (nothing persisted). But it is very misleading in the log / summary counts — you can't tell whether 1 of 200 or 200 of 200 forfeits actually failed. Consider issuing one patch per txid so the counter reflects reality, or aggregating failures inside PatchForfeitTxs and returning them.
The badger backend (internal/infrastructure/db/badger/ark_repo.go:926-941) has the opposite problem: no transaction, so a mid-batch failure leaves earlier updates committed while Failed += len(patch) still reports them all as failed. Re-runs recover because idempotence kicks in, but the counter is again inflated.
5. Backfill has no concurrency guard against a running arkd
The command doc (cmd/arkd-forfeit-backfill/main.go:8-14) only requires "the arkd-wallet signer must be running and unlocked" — nothing says arkd itself must be stopped. If an operator runs the backfill while arkd is live, PatchForfeitTxs can race with broadcastForfeitTx. Both paths are guarded by forfeitTxOperatorSigned, so the outcome is benign (arkd will re-read the newly-signed tx or sign a legacy one itself), but this is worth documenting explicitly; otherwise the operator's assumption is undefined.
Minor
Config.RepoManager()(internal/config/config.go:715-622) — public wrapper around a privaterepoManager(); naming/pattern is slightly inconsistent withSignerService()above it, which is fine, but consider colocating docstrings.findForfeitTxininternal/backfill/backfill.go:241-255is a near-duplicate of the one ininternal/core/application/fraud.go:390-418(minus the connector-outpoint return). Fine as commented, but exporting a shared helper would prevent drift.- The badger
PatchForfeitTxsguards on "txid not found" but not on the underlying Tx record's type (there is none) — comment already explains this. Since txids are cryptographically unique, safe in practice. signForfeitTxs(service.go:2714) iterates serially. For large rounds this adds gRPC round-trips × forfeits to the finalization critical path; consider batching or parallelizing if this shows up in round-length metrics.
Positive
- TDD + real DB coverage for
PatchForfeitTxson sqlite AND postgres (internal/infrastructure/db/service_test.go:721-…), plus badger, is thorough. - Deprecated-key handling in the "already signed" check correctly prevents re-signing forfeits from before a key rotation.
- No schema migration needed and no on-boot logic — good ops story for the backfill.
Not approving — this needs a human protocol reviewer, particularly on points 1–3.
|
This PR has been open for 3+ days without review. @Kukks is anyone looking at this? |
arkana-ai-bot
left a comment
There was a problem hiding this comment.
Review: Sign forfeit txs eagerly (collection-time + standalone backfill)
What it does
- Eager signing at collection time: Signs the operator's half of forfeit txs when a VTXO is collected into a round, rather than deferring.
arkd-forfeit-backfilltool: A standalone command to retroactively sign operator halves for vtxos persisted before this change.- Backfill
PatchForfeitTxs: New repository method to batch-update persisted forfeit txs.
Design assessment
Correctness: Signing at collection time is the right place — the operator has committed to the vtxo being in the round and the forfeit tx structure is known. Deferred signing introduced a window where the operator's half might be missing when a forfeit needs to be broadcast.
Backfill tool design: Well-structured.
- Scans only unswept, forfeited vtxos (the only ones where forfeit txs could still be broadcast).
- Idempotent: already-signed txs are detected by checking for the operator's pubkey (current + deprecated) in the PSBT inputs and skipped.
- Per-forfeit failures are logged/counted but don't abort the run, so re-runs retry only failures.
- Non-zero exit when
Failed > 0— operator/script can detect and retry. - Deprecated keys are included in the "already signed" check — correct for post-rotation backfill.
Questions for human reviewer
- Concurrency: If arkd is running and collecting new rounds while the backfill runs, could
PatchForfeitTxsrace with in-progress collection? The backfill only touchesforfeitedvtxos (already swept), so there should be no overlap — but confirm the vtxo state machine prevents a vtxo from being both "forfeited" and "active in a round" simultaneously. - PSBT operator-presence check: How is "already signed by operator" detected? Is it checking for a Schnorr sig from any of the operator's keys in the tapscript spend path, or something else? Confirm the check is robust against partial/malformed PSBTs.
- Migration path: Should operators run the backfill before or after deploying the new arkd? The README or release notes should be explicit.
Ready for human sign-off. The implementation looks sound; the questions above are for the human reviewer to verify against the full context.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
Arkana review — Sign forfeit txs eagerly + backfill tool
This is a protocol-critical change. The approach is sound and the implementation is careful.
What changes: The server now signs its half of forfeit txs at collection time rather than only at fraud-detection time. The new arkd-forfeit-backfill command signs historical forfeit txs that were persisted before this change.
Backfill correctness:
- Only processes unswept forfeited vtxos that
RequiresForfeit()— the only forfeits that could ever still be broadcast - Skips already-operator-signed forfeits (idempotent, safe to re-run)
- Includes deprecated keys in the "already signed" check, so pre-rotation forfeits are not re-signed with the wrong key
- Per-forfeit failures are logged and counted but don't abort the run — the operator sees a non-zero exit and can re-run
- Groups by commitment txid to load each round once
forfeitOperatorSigned: Correctly checks TaprootScriptSpendSig.XOnlyPubKey against all operator keys (current + deprecated). Straightforward.
Tests: backfill_test.go at 320 lines — appears comprehensive from the import set (mocks, real psbt construction, key rotation scenarios). Well-structured.
One question for the author: does the backfill need to handle the case where a vtxo's commitment tx has been garbage-collected from the rounds table? GetRoundWithCommitmentTxid failure is counted as Failed — is there a retention policy that could cause silent data loss here, or are settled rounds always retained?
Conflicts came from master dropping the PatchCollectedFees backfill idiom this PR was modelled on, and renaming two things the new e2e test used. - round_repo.go, badger, sqlite/postgres sqlc: keep PatchForfeitTxs, take master's SumCollectedFees, drop PatchCollectedFees. Both generated query.sql.go files verified byte-identical to sqlc regeneration. - service_test.go: keep test_patch_forfeit_txs, drop test_patch_collected_fees. - e2e_test.go: types.Vtxo -> clientlib.Vtxo, redemption -> unroll. - backfill: master's RequiresForfeit no longer excludes expired vtxos (#94), so drop the expired case from TestBackfillSkipsNonForfeitableVtxos (it was passing vacuously via a missing fake round) and fix the stale package doc.
Reject submitted forfeits carrying operator signatures. VerifyForfeitTxs only asserts len(TaprootScriptSpendSig) > 0 on the vtxo input, and its txid rebuild ignores witness data, so a client could plant a TaprootScriptSpendSig under the operator's x-only key over the real leaf. Both the key and the leaf are known to the client, so it is trivially constructible. Collection-time signing then appends a second entry for that same (key, leaf) pair and the psbt stops parsing, so signForfeitTxs fails and takes the whole round down for every participant, with no conviction recorded against whoever did it. Confirmed against the psbt library: one planted entry decodes fine, the appended duplicate gives "Invalid Psbt due to duplicate key". SubmitForfeitTxs now rejects a forfeit carrying an operator-key tapscript sig or any key spend sig, neither of which a client can legitimately produce. It fails closed when no operator key is configured, since Settings.SignerPubkey is nillable and an empty key set would compare against nothing and pass everything. Decide broadcast readiness from the psbt's own leaf, not the current key set. Matching against SignerPubkey plus the deprecated keys meant that after a hard rotation a pre-signed forfeit went unrecognized and was re-signed with a key its leaf does not commit to, making the eager signing pointless in exactly the case it exists for. domain.ForfeitTxReadyToBroadcast asks what the finalizer needs instead: a signature for every pubkey the leaf commits to, plus the connector key spend sig. It consults no key set, so a forfeit outlives the key that signed it. The backfill shares it and drops both its own copy and the operator key getters. Verified on regtest: the pre-signed forfeit is broadcast with zero SignTransactionTapscript calls in the fraud window, against one before this change. TestReactToFraud and TestEagerForfeitSurvivesWalletRotation both pass. Retry backfill patches one by one when the batch fails. The SQL backends patch a round in one transaction, so one bad txid rolled back every forfeit in that round, re-runs failed identically forever, and the counter blamed the whole batch. Failures are now attributed to the txid that caused them and the healthy forfeits still persist. Also document that the backfill may run alongside a live arkd and that it reads every vtxo before filtering, and record what the rotation e2e test does and does not establish on its own.
Match the repo's Go test style, already applied to service_forfeit_test.go in this branch: one TestBackfill with t.Run subtests at the top, test doubles and helpers at the bottom. Test bodies unchanged. Two golines wraps from the deeper indentation.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
🔍 Arkana — Follow-up Review #2 (558b9f6)
Status: 2 new commits since f38c73 ( + ). The master merge brought in many unrelated files; my scope is the 8 PR-specific files changed in those two commits.
Prior-issue scorecard: 3 fixed, 1 addressed (doc), 2 still open. Details below.
Prior Issues — Closed Loop
1. ✅ Fixed — UpdateForfeitTx silently succeeding on non-existent txid
Annotation changed from :exec to :execrows in both postgres/sqlc/query.sql and sqlite/sqlc/query.sql. Both round_repo.go implementations now check affectedRows == 0 and return a named error. The per-txid retry in backfill.go:141-151 then correctly attributes failures to the specific txid rather than writing off the whole batch. This is the right fix.
2. ✅ Fixed (documentation) — GetAllVtxos memory footprint
cmd/arkd-forfeit-backfill/main.go and backfill/backfill.go package comment now both note the full-vtxo-set read and advise running off-peak. Acceptable for a one-shot operator tool.
3. ✅ Fixed (documentation) — SQLite concurrent access
cmd/arkd-forfeit-backfill/main.go doc comment now explicitly states the tool may run alongside a live arkd and explains why that is safe (both sides are idempotent and read the stored PSBT to decide what to do). Acceptable.
4. 🟡 Still open — No ctx.Done() check in backfill loops
internal/backfill/backfill.go:77-152 — the main iteration over commitment groups and per-vtxo work still has no context-cancellation check. For a one-shot operator tool this is low severity (the operator can kill the process), but for very large deployments the signer timeout is the only interrupt path. A ctx.Err() guard at the top of the outer loop would be the fix. Not blocking.
5. 🟡 Still open — findForfeitTx duplication
backfill.go:165 and fraud.go:504. The PR intentionally avoids an application-package import from backfill. The two functions remain without cross-references; add a comment in each pointing to the other so they stay in sync. Not blocking.
New Findings
A. ✅ ForfeitTxReadyToBroadcast design — correct and well-reasoned
internal/core/domain/forfeit.go:35-72
The leaf-based readiness check is cleaner and more correct than the prior key-set approach. It correctly handles key rotations: a forfeit signed with a rotated-away key is still recognized as ready because the check reads the pubkeys from the leaf itself, not from the current operator key set. The doc comment is exemplary. Verified against VerifyForfeitTxs in the builder, which at line 363 rejects submitted forfeits with no TaprootLeafScript on the vtxo input — this means all stored forfeit PSBTs do have the leaf script populated, so the readiness check will never silently misfire on old records.
B. ℹ️ Informational — signed map keys on XOnlyPubKey only, not (XOnlyPubKey, LeafHash)
internal/core/domain/forfeit.go:52-60
A tapscript sig carries both a pubkey and a leaf hash; a sig for the right key over the wrong leaf is invalid but would be counted as "present" here. The doc comment acknowledges this: "This reports what the psbt carries, not whether those signatures verify." In normal operation this cannot happen (the signer signs the correct leaf), but it is worth noting that a false-positive from ForfeitTxReadyToBroadcast — caused by a PSBT with a sig for the right key over the wrong leaf — would cause broadcastForfeitTx to skip re-signing and then fail at finalization. The operator would see a broadcast error and need to investigate. No exploit path; log if finalization fails after a "ready" check so the operator has a signal.
C. ✅ ForfeitTxCarriesOperatorSignature — correct
internal/core/domain/forfeit.go:80-97
The zero-length key guard (len(key) > 0 &&) is correct and necessary. Any key-spend sig on any input is correctly flagged (the connector is the only key-path spend in a forfeit, and a client cannot produce it). The function is called before cache.ForfeitTxs().Sign in SubmitForfeitTxs, at the right point in the pipeline.
D. 🟡 New failure mode on hot path — SubmitForfeitTxs fails if no operator key is configured
internal/core/application/service.go:2377
operatorXOnlyKeys() returns an error when settings.SignerPubkey is nil, which causes every SubmitForfeitTxs call to return INTERNAL_ERROR until the signer key is available. Fail-closed is the right security choice — an empty key set would silently wave through every submitted forfeit — but this means a temporarily unconfigured signer blocks all participants from submitting forfeits in the current round. The existing INTERNAL_ERROR response and log line are sufficient; operators should monitor for this error class. Already noted in the PR description; confirming it is visible.
E. ✅ Per-txid retry counting — no double-count
internal/backfill/backfill.go:134-152
When the batch fails and falls into per-txid retry, the inner loop increments res.Signed or res.Failed per txid, and the outer continue correctly skips the res.Signed += len(patch) that follows the batch-success path. Counts are correct. The new tests "salvages forfeits when the batch patch fails" and "counts only the forfeit that failed to persist" cover both sub-cases.
F. ✅ New test coverage
TestForfeitTxs/broadcast_readinesscorrectly exercises ready/not-ready cases: user-only sig, missing connector key-spend sig, sig from a key outside the leaf.TestBackfill/skips forfeits signed with rotated away key— the new subtest correctly tests the key-rotation case: the forfeit is signed witholdPub(not retained as deprecated), and the currentxOnlyis different.ForfeitTxReadyToBroadcaststill returns true because it reads the leaf, not the key set. This is the property the whole design pivots on.TestVerifyForfeitTxsSigsSurvivesNilPacketcovers the nil-packet edge case in fraud.go. Good.
Summary
The two new commits significantly harden the PR: the DoS vector (round-failing planted sig) is closed by ForfeitTxCarriesOperatorSignature in SubmitForfeitTxs, the key-rotation correctness bug is fixed by ForfeitTxReadyToBroadcast reading from the leaf, and the critical silent-no-op SQL bug (Issue 1 from prior review) is fixed. The remaining open items (Issues 4 and 5) are non-blocking for a backfill tool. No new blocking issues found in this diff.
Requesting changes for protocol-critical path — human sign-off still required per policy — but the incremental changes are clean and well-tested.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
Arkana — Follow-up Review #2 (558b9f6)
Status: 2 new commits since f38c73 ("forfeit: reject planted operator sigs" and "test(backfill): tests on top as TestBackfill subtests"). The master merge brought in many unrelated files; scope is the 8 PR-specific files changed in those two commits.
Prior-issue scorecard: 3 fixed, 1 addressed (doc), 2 still open. Details below.
Prior Issues — Closed Loop
1. Fixed — UpdateForfeitTx silently succeeding on non-existent txid
Annotation changed from :exec to :execrows in both postgres/sqlc/query.sql and sqlite/sqlc/query.sql. Both round_repo.go implementations now check affectedRows == 0 and return a named error. The per-txid retry in backfill.go:141-151 then correctly attributes failures to the specific txid rather than writing off the whole batch. This is the right fix.
2. Fixed (documentation) — GetAllVtxos memory footprint
cmd/arkd-forfeit-backfill/main.go and backfill/backfill.go package comment both now note the full-vtxo-set read and advise running off-peak. Acceptable for a one-shot operator tool.
3. Fixed (documentation) — SQLite concurrent access
cmd/arkd-forfeit-backfill/main.go doc comment now explicitly states the tool may run alongside a live arkd and explains why that is safe. Acceptable.
4. Still open — No ctx.Done() check in backfill loops
internal/backfill/backfill.go:77-152 — the main iteration over commitment groups has no context-cancellation check. For a one-shot operator tool this is low severity (the operator can kill the process), but a ctx.Err() guard at the top of the outer loop is the clean fix. Not blocking.
5. Still open — findForfeitTx duplication
backfill.go:165 and fraud.go:504. The two copies remain without cross-references. Add a comment in each pointing to the other. Not blocking.
New Findings
A. ForfeitTxReadyToBroadcast design — correct and well-reasoned
internal/core/domain/forfeit.go:35-72
The leaf-based readiness check is cleaner and more correct than the prior key-set approach. Key rotations are handled: a forfeit signed with a rotated-away key stays recognized as ready because the check reads pubkeys from the leaf itself. The doc comment is exemplary. Verified: VerifyForfeitTxs in the builder rejects submitted forfeits with no TaprootLeafScript on the vtxo input (builder.go:363), so all stored forfeit PSBTs have the leaf script populated and the readiness check will never silently misfire on old records.
B. Informational — signed map keys on XOnlyPubKey only, not (XOnlyPubKey, LeafHash)
internal/core/domain/forfeit.go:52-60
A tapscript sig carries both a pubkey and a leaf hash; a sig for the right key over the wrong leaf is invalid but would be counted as "present" here. The doc comment acknowledges this ("This reports what the psbt carries, not whether those signatures verify"). In normal operation this cannot occur. A false positive would cause broadcastForfeitTx to skip re-signing and then fail at finalization — the operator would see a broadcast error. No exploit path. Suggestion: log a warning if finalization fails after a "ready" check.
C. ForfeitTxCarriesOperatorSignature — correct
internal/core/domain/forfeit.go:80-97
The zero-length key guard (len(key) > 0 &&) is correct and necessary. Any key-spend sig on any input is correctly flagged. The check is placed before cache.ForfeitTxs().Sign in SubmitForfeitTxs, at the right point in the pipeline.
D. New failure mode on hot path — SubmitForfeitTxs fails if no operator key is configured
internal/core/application/service.go:2377
operatorXOnlyKeys() returns an error when settings.SignerPubkey is nil, causing every SubmitForfeitTxs call to return INTERNAL_ERROR until the signer key is available. Fail-closed is the correct security choice (an empty key set would silently accept all submitted forfeits), but this means a temporarily unconfigured signer blocks all participants in the current round. The existing error log is the right signal; operators should monitor for this error class. Already noted in the PR description; confirming it is visible here.
E. Per-txid retry counting — no double-count
internal/backfill/backfill.go:134-152
When the batch fails and falls into per-txid retry, the inner loop increments res.Signed or res.Failed per txid, and the outer continue correctly skips the res.Signed += len(patch) in the batch-success path. Counts are correct. The new tests "salvages forfeits when the batch patch fails" and "counts only the forfeit that failed to persist" cover both sub-cases.
F. New test coverage — adequate
TestForfeitTxs/broadcast_readinesscorrectly exercises ready/not-ready cases: user-only sig, missing connector key-spend sig, sig from a key not in the leaf.TestBackfill/skips forfeits signed with rotated away key— the forfeit is signed witholdPub(not retained as deprecated) and the current key is different.ForfeitTxReadyToBroadcaststill returns true because it reads the leaf. This is the property the whole design pivots on; having it pinned in a test is important.TestVerifyForfeitTxsSigsSurvivesNilPacketcovers the nil-packet edge case in fraud.go.
Summary
The two new commits significantly harden the PR. The DoS vector (round-failing planted operator sig) is closed by ForfeitTxCarriesOperatorSignature in SubmitForfeitTxs. The key-rotation correctness bug is fixed by ForfeitTxReadyToBroadcast reading from the leaf rather than the key set. The critical silent-no-op SQL bug (Issue 1 from prior review) is fixed. Remaining open items (Issues 4 and 5) are non-blocking for a backfill tool. No new blocking issues found in this diff. Requesting changes only because this is a protocol-critical path that requires human sign-off per review policy.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
Arkana review — sha d8a4906 (previously reviewed sha f38c739)
The new commits add a standalone backfill tool (cmd/arkd-forfeit-backfill + internal/backfill/). The main collection-time signing logic (reviewed previously) is unchanged.
Backfill tool review:
Design is sound: narrow interfaces (VtxoSource, ForfeitStore, Signer) keep it decoupled from the main application and independently testable. Idempotency is a first-class concern — ForfeitTxReadyToBroadcast gates all signing, and the test suite verifies the signer isn't called on a second run.
Correctness observations:
-
Batch-then-retry is correct. When the batch
PatchForfeitTxscall fails, the code retries each forfeit individually. This handles the described SQL-backend case where a single bad txid rolls back the whole batch. -
SettledByas commitment txid. The filterv.IsSettled() && v.RequiresForfeit()relies on these methods being correct on vtxos. Worth confirming thatSettledByis always the commitment txid (not a round ID or other identifier) for the repo call that follows. -
Large-deployment load warning in the doc comment is important: the tool loads all vtxos in one call. The operator notes say to prefer running it off-peak — good. Consider adding an explicit note in the README or operator guide about this too.
-
The 'rotated-away key' test is a good edge case: forfeits signed with a no-longer-held key that are already broadcast-ready should be left alone. The test correctly asserts
AlreadySigned=1andsigner.calls=0.
Prior review questions answered: the eager collection-time signing path is now paired with this backfill for historic gaps. The combination looks complete for the operator's forfeit coverage.
No new concerns. Looks ready pending questions above and human sign-off.
|
Changes were requested 71+ days ago on this protocol-critical forfeit signing PR. @Kukks need any help addressing the feedback? |
Follow-up review #2. Two open items and one new finding. Readiness counted a signature as present on its pubkey alone. A tapscript sig is valid only for the script it was made over, which the psbt records as its leaf hash, and one vtxo can expose several forfeit closures that all carry the operator key (ForfeitClosures returns a slice). Matching on pubkey alone would therefore accept a sig made for a sibling leaf, report ready, and fail at finalization with fraud unpunished and no obvious cause. Match on the leaf hash as well. The review rated this informational and suggested logging after the failure; prevent it instead, since a multi-forfeit-closure vtxo is an ordinary shape rather than a legacy artifact. The backfill ignored cancellation. A ctx.Err() check alone would have been theatre, since main passed context.Background() and nothing ever cancelled it: wire SIGINT/SIGTERM through signal.NotifyContext and stop between rounds, so an interrupted run leaves whole rounds patched and still reports its counts. findForfeitTx in internal/backfill already pointed at the fraud.go original; add the reverse pointer so the two stay in sync. Verified: e2e TestReactToFraud and TestEagerForfeitSurvivesWalletRotation pass on regtest with the leaf-hash check, confirming the signer's own sigs match the leaf they are produced over.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
Follow-up pass — 2 commits since baseline (d8a4906, 7fbdef7): all 5 prior issues resolved, 1 new correctness finding (now also fixed in this same push), 0 remaining open issues.
Prior-issue closure
Issue 1 — UpdateForfeitTx silently succeeds on missing txid (SQL backends): Fixed.
Both backends now use :execrows annotation (query.sql.go — UpdateForfeitTx returns int64). Both round_repo.go implementations check affectedRows == 0 and return fmt.Errorf("forfeit tx %s not found", txid). Confirmed in generated code: result.RowsAffected() is returned and checked by callers.
Issue 2 — GetAllVtxos memory footprint undocumented: Fixed.
cmd/arkd-forfeit-backfill/main.go package doc (head): "It loads every vtxo in one call before filtering. On a large deployment that is a big read, so prefer running it off peak."
Issue 3 — SQLite concurrent-access warning missing: Fixed.
main.go package doc explicitly documents that arkd may keep running concurrently and describes both sides' idempotency. The narrower SQLite WAL concern remains advisory-level; the operator is informed.
Issue 4 — No ctx.Done() check in backfill loop: Fixed.
backfill.go now checks ctx.Err() at the top of every commitmentTxid iteration and returns res, nil on cancellation. Corresponding test ("stops between rounds when the context is cancelled") covers the pre-cancelled case: zero signing calls, zero patches. Signal wiring in main.go uses signal.NotifyContext with SIGTERM/SIGINT.
Issue 5 — findForfeitTx duplication without cross-reference: Fixed.
fraud.go:474 now carries "A trimmed copy lives in internal/backfill (findForfeitTx), which cannot import this package; keep the two in sync." backfill.go:findForfeitTx carries the reciprocal reference.
New findings from d8a4906 → 7fbdef7
✅ Correctness fix — leaf-hash filter in ForfeitTxReadyToBroadcast (forfeit.go:53-58)
The prior implementation keyed the signed map on XOnlyPubKey alone. A TaprootScriptSpendSig entry is valid only for the (pubkey, leaf_hash) pair it was made for; matching on pubkey alone would accept a sig made over a sibling forfeit closure that carries the same operator key. That forfeit would appear broadcast-ready but fail finalization.
The fix:
leafHash := txscript.NewBaseTapLeaf(in.TaprootLeafScript[0].Script).TapHash()
for _, sig := range in.TaprootScriptSpendSig {
if !bytes.Equal(sig.LeafHash, leafHash[:]) {
continue
}
signed[string(sig.XOnlyPubKey)] = struct{}{}
}This is correct. TaprootLeafScript[0] is the leaf the PSBT was constructed to spend; filtering signatures by its hash before checking pubkey coverage matches what the finalizer will enforce. The new test "not ready when a leaf pubkey signed a different leaf" (service_forfeit_test.go) directly covers this path. Good fix.
✅ Test fidelity improvement — spendSig / operatorSig now use real leaf hashes
Both helpers previously used make([]byte, 32) (all-zeros) as LeafHash. With the leaf-hash filter now live, a zero LeafHash would only match a sig if the computed leafHash[:] were also all-zeros — never true in practice. The helpers now compute txscript.NewBaseTapLeaf(leaf).TapHash() from the supplied script, making test doubles faithful to the real invariant. Correct change.
ℹ️ One minor observation — ForfeitTxCarriesOperatorSignature intentionally does not apply the leaf-hash filter (forfeit.go:85-98)
This is correct by design: SubmitForfeitTxs must reject any forfeit arriving with an operator-key sig regardless of which leaf it purports to cover. The asymmetry between the two functions is sound.
What looks good overall
- Protocol correctness of the new
ForfeitTxReadyToBroadcastlogic: verified againstTaprootLeafScript[0](the spending path the PSBT records), consistent with whatbtcd's finalizer checks. - Cancellation propagates cleanly:
ctxflows from signal handler →Run→GetRoundWithCommitmentTxid/SignTransactionTapscript, so a SIGTERM drains the current round and exits promptly with counts reported. - The
--request-changesfrom the prior pass is superseded; no outstanding blocking issues remain from my side. Human protocol sign-off is still required given the forfeit-path criticality.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
Review update — #1110 (forfeit presigning, CHANGES_REQUESTED)
Since the last review the PR has grown a standalone backfill tool (cmd/arkd-forfeit-backfill) and removed the per-process offchainTxMu mutex, replacing it with the atomic OffChainTxStore.Add that now returns a ClaimStatus.
Backfill tool:
- Correctly scopes to unswept forfeited vtxos that still
RequiresForfeit()— notes, swept, and unrolled vtxos are excluded. - Idempotent: skips forfeits that are already broadcast-ready per
ForfeitTxReadyToBroadcast. - The per-forfeit retry on batch-patch failure is a good defensive move (one bad txid on a SQL backend rolling back the whole round was the problematic scenario; the individual-retry loop handles it).
- Context cancellation is checked between rounds rather than mid-round, which is the right granularity for safe restarts.
- Test coverage is solid: skips non-forfeitable vtxos, is idempotent, counts only the failing forfeit (not the whole batch), handles signer errors, and stops cleanly on cancellation.
offchainTxMu removal:
- Replacing the double-check-then-add (recheck under mutex + add) with a single atomic Add returning ClaimStatus is the right design: the store is the barrier, a per-process mutex was never cross-process safe anyway.
- The
ClaimAlreadyOwnedpath (idempotent retry) correctly suppresses duplicate Accepted events by settingchanges = nil. - Reviewed the
ClaimConflictpath — returns VTXO_ALREADY_SPENT correctly.
Remaining question from last review: the CHANGES_REQUESTED reviewer's concern should be addressed separately. I don't see a response in the thread visible to me — @author can you confirm the original reviewer's items are resolved?
arkana-ai-bot
left a comment
There was a problem hiding this comment.
Arkana review — Sign forfeit txs eagerly (sha 7fbdef7)
New since last review (sha f38c739)
The new commit adds cmd/arkd-forfeit-backfill and internal/backfill/backfill.go.
Backfill tool assessment
The arkd-forfeit-backfill tool signs operator halves of forfeit txs that were persisted before this feature landed. Design notes:
Idempotency: domain.ForfeitTxReadyToBroadcast(forfeitTx) check ensures already-signed forfeits are skipped — safe to re-run. ✓
Concurrency with live arkd: The comment says both sides are idempotent and the worst case is a duplicate signing. That's correct as long as PatchForfeitTxs is atomic per forfeit — a half-patched forfeit tx would be problematic. Confirm PatchForfeitTxs is either atomic per entry or operates in a transaction.
Load concern documented: "Loads every vtxo in one call before filtering. On a large deployment that is a big read, so prefer running it off peak." — the comment is there, which is good.
Error handling: Per-vtxo failures are logged and counted but don't abort the run. The non-zero exit on any failure lets wrapping scripts retry. ✓
Interruption handling: signal.NotifyContext stops cleanly between rounds. Re-running resumes from where it left off (only unsigned forfeits remain). ✓
One gap
The tool uses GetAllVtxos which returns every vtxo in the database. For a deployment with millions of vtxos, this could be a large allocation. Consider whether the backfill should chunk vtxos by SettledBy commitment txid using a paginated query rather than the full scan — even if the comment says "off peak", a 10M vtxo deployment will OOM. Not a blocker for the protocol correctness but worth filing a follow-up.
Overall: the backfill tool is well-designed for its purpose. The main PR logic (signing forfeit txs at collection time) was already reviewed and looked correct.
|
let's simplify the PR by removing the tool to backfill and sign old forfeits |
Per review: the readiness check is enough to make collection-time signing non-breaking, so the tool comes out and old forfeits can be patched by hand against the DB if it ever proves necessary. Removed: cmd/arkd-forfeit-backfill, internal/backfill, its build script and the build-all wiring. Also removed, because nothing else used them and leaving them would be speculative API with no caller: - RoundRepository.PatchForfeitTxs, its sqlite, postgres and badger implementations, the UpdateForfeitTx query in both sqlc sources, and the regenerated output - Config.RepoManager, added only so the tool could build a repo manager What stays is the part the review says is sufficient. broadcastForfeitTx still decides from domain.ForfeitTxReadyToBroadcast whether a stored forfeit already carries every signature it needs, and signs only when it does not, so forfeits written before collection-time signing keep working untouched.
backfill code removed in: 97e2ab1 Gist: https://gist.github.com/bitcoin-coder-bob/f16d9f57144d356d1b2e508c152461c7 |
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
Follow-up pass — 97e2ab1 vs 7fbdef7
Single commit since last review: "Drop the forfeit backfill tool and the repo method it needed." Every change is a deletion — no new functional code was introduced. All five prior issues are resolved by removal of the subsystem they lived in.
Prior-issue closure
Issue 1 🔴 — UpdateForfeitTx silently succeeds on 0 rows (postgres/sqlite :exec).
→ Fixed. PatchForfeitTxs and the UPDATE tx SET tx = @tx WHERE txid = @txid query were removed from all three backends (internal/infrastructure/db/postgres/sqlc/query.sql, sqlite/sqlc/query.sql, and the badger impl).
Issue 2 🟡 — GetAllVtxos loads entire vtxo set into memory (backfill.go:61).
→ Fixed. internal/backfill/backfill.go removed entirely.
Issue 3 🟡 — SQLite concurrent access with a running arkd.
→ Fixed. The standalone cmd/arkd-forfeit-backfill binary removed; the concern no longer applies.
Issue 4 🟡 — No ctx.Done() check within the per-vtxo inner loop (backfill.go:77-132).
→ No longer applicable. internal/backfill/backfill.go removed. (For the record: the outer per-round loop did check ctx.Err(); the gap was only in the inner per-vtxo loop.)
Issue 5 ℹ️ — findForfeitTx duplicated between backfill.go and fraud.go.
→ Fixed. backfill.go removed; the corresponding "keep these in sync" comment at fraud.go:474-476 was also cleaned up in this commit (the only functional change to any previously-reviewed file).
Incremental findings
The 97e2ab1 diff is pure deletion — no new logic, no new interfaces, no new error paths. There is nothing to flag beyond the one operational point below.
🟡 Operational: no offline migration path for legacy unsigned forfeits
Removing the backfill tool means operators who upgrade arkd with a backlog of forfeits stored before collection-time signing was introduced have no offline way to pre-sign those forfeits. The only remaining path for a legacy unsigned forfeit is the conditional re-sign inside broadcastForfeitTx (fraud.go:222-231), which requires the signer to be reachable at fraud-reaction time.
This is not worse than pre-PR behaviour (the signer was always required before this change), but it is worth stating explicitly in the PR description or release notes so operators are not surprised:
Post-upgrade forfeits are broadcast-ready at rest. Pre-upgrade forfeits (unswept, unsigned) still require a live signer at fraud-reaction time. There is no longer an offline backfill path.
If operator tooling already documents this, or if the team is comfortable accepting the signer-liveness requirement for the legacy tail, no code change is needed — just confirm it is intentional and documented.
Remaining code (unchanged from last pass) — no new findings
The code that stays in the PR — signForfeitTxs, operatorXOnlyKeys, the poison-PSBT guard in SubmitForfeitTxs, ForfeitTxReadyToBroadcast, ForfeitTxCarriesOperatorSignature, and service_forfeit_test.go — was covered in the prior review and has not changed. No new findings.
Verdict
The --request-changes flag is set solely because the protocol-critical label mandates a human sign-off before merge, not because of a blocking code defect. The single open item (legacy-forfeit operational note) needs a documented acknowledgement — either in the PR description or in operator release notes — but does not require a code change. Once a human reviewer signs off, this is mergeable.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
Status: All 5 issues from the previous pass are now no longer applicable — the backfill tool, PatchForfeitTxs, and Config.RepoManager were dropped entirely. The incremental diff (97e2ab1→d74cd77) is 6 files, ~500 lines, introducing ForfeitTxReadyToBroadcast, ForfeitTxCarriesOperatorSignature, collection-time signing in signForfeitTxs, and a new e2e test. 0 of 5 prior issues remain open. 2 new findings, both yellow.
Prior-issue closure
UpdateForfeitTxsilent success on non-existent txid — No longer applicable.PatchForfeitTxsand every SQL backend that implemented it are gone.GetAllVtxosloading entire vtxo set — No longer applicable.backfill.Runis gone.- SQLite concurrent-access hazard — No longer applicable. The CLI tool is gone.
- No
ctx.Done()check in backfill loops — No longer applicable. The loops are gone. findForfeitTxduplication — No longer applicable. The backfill copy is gone.
New findings
1. 🟡 Corrupted stored sig bytes cause silent forfeit-broadcast failure (internal/core/domain/forfeit.go:18–59, internal/core/application/fraud.go:214–228)
ForfeitTxReadyToBroadcast checks presence, not cryptographic validity. If a stored forfeit PSBT has structurally correct but cryptographically invalid sig entries — due to DB bit-rot, a signer returning garbage bytes at collection time, or any other in-flight corruption — the function returns true, broadcastForfeitTx skips the live re-sign, and FinalizeAndExtract fails. The forfeit is not broadcast, and the signer is never called as a fallback. The vtxo goes unpunished until the DB entry is manually repaired.
This is a resilience regression vs. the always-sign path: previously FinalizeAndExtract would have received fresh valid sigs and broadcast the forfeit regardless of what was stored.
The trade-off is understood and necessary — always re-signing would append a duplicate PSBT key for already-signed forfeits, producing an invalid packet. But the failure mode should be documented in the function's doc comment and, ideally, monitored: if FinalizeAndExtract fails on a ForfeitTxReadyToBroadcast == true forfeit, the operator needs an alert that a manual DB repair is required to recover the punishable vtxo.
// internal/core/domain/forfeit.go:18
// ForfeitTxReadyToBroadcast reports whether a forfeit psbt carries every
// signature its finalizer needs. It checks presence, not validity ...
//
// ⚠️ If sig bytes are corrupted after signing, this returns true but
// FinalizeAndExtract will fail. In broadcastForfeitTx the live signer is not
// called as a fallback in that case — manual DB repair is required.And in broadcastForfeitTx, a failure from FinalizeAndExtract on a ReadyToBroadcast == true forfeit should log distinctly so operators can distinguish "legacy unsigned" from "stored-but-corrupt".
2. 🟡 Input-position ambiguity in ForfeitTxReadyToBroadcast (internal/core/domain/forfeit.go:23–32)
Inputs without TaprootLeafScript are treated as key-spend connectors, checked for TaprootKeySpendSig. This relies on the invariant that forfeit PSBTs always have input 0 = vtxo (script-spend, TaprootLeafScript populated) and input 1 = connector (key-spend, no TaprootLeafScript). The function iterates by position without asserting this. For well-formed PSBTs from signForfeitTxs → checkForfeit → storage this is fine. Add a comment stating the precondition.
What looks correct
Security chain is complete. The three-stage guard (SubmitForfeitTxs rejects PSBTs with operator sigs → signForfeitTxs adds valid operator sig → ForfeitTxReadyToBroadcast skips re-sign at broadcast) is logically closed. A client cannot inject garbage operator-key entries through SubmitForfeitTxs because ForfeitTxCarriesOperatorSignature fires first. This eliminates the pre-signing injection vector entirely.
operatorXOnlyKeys fail-closed (service.go:2339–2356). The guard if len(keys) <= 0 { return nil, error } prevents silent pass-through when no key is configured. The INTERNAL_ERROR response to the client is correctly opaque.
ForfeitTxCarriesOperatorSignature (domain/forfeit.go:63–80). Rejects any key-spend sig on any input (not just the connector), and uses len(key) > 0 to prevent nil-key false-match. Correct.
signForfeitTxs error semantics. Signing failure on any single forfeit fails the entire finalizeRound via round.Fail. Correct — a partially signed set must not be committed.
fraud.go conditional re-sign (fraud.go:214–228). Uses the already-parsed *psbt.Packet (forfeitTx) for the readiness check and re-encodes forfeitTxB64 as the passthrough value. No double-decode. Variable scoping is correct (err already in scope from the B64Encode call at line 209).
domain/forfeit_test.go coverage. Tests for ForfeitTxReadyToBroadcast cover: all sigs present (true), user-only (false), missing connector key-spend (false), sig over sibling leaf (false), sig from non-leaf key (false). Covers the leaf-hash matching path specifically called out in my prior pass. Good.
E2E test TestEagerForfeitSurvivesWalletRotation correctly captures the end-to-end outcome. The acknowledged limitation — that the test can't prove the signer was NOT called during fraud reaction without splitting wallet/signer processes — is honestly documented. The time.Sleep(8 * time.Second) for fraud reaction timing is fragile under CI load but expected for regtest.
Minor
internal/core/domain/forfeit_test.go: thelen(ptx.Inputs) <= 0guard atforfeit.go:20is not tested (zero-input PSBT → false). Trivial to add.ForfeitTxCarriesOperatorSignatureis tested for thefalsecase in domain tests; thetruepath is covered only via the application-layerTestForfeitTxs. Both live under the PR, so coverage is there — just split across packages.
Requesting changes for finding #1 (documentation gap on the broadcast-failure-silent mode) before merge. Finding #2 is a comment request only.
arkana-ai-bot
left a comment
There was a problem hiding this comment.
PROTOCOL-CRITICAL: human review required.
Follow-up pass — 1 new commit since d74cd77 (4d04577 revert query.sql files changes): 0 prior issues fixed, 5 still open.
The single new commit removes a trailing blank line from internal/infrastructure/db/postgres/sqlc/query.sql and internal/infrastructure/db/sqlite/sqlc/query.sql. Both changes are cosmetic (whitespace only). No functional code was modified.
Prior-issue status
All five issues from the previous review remain Still open — the new commit does not touch any of the relevant files.
1. 🔴 Still open — UpdateForfeitTx silently succeeds on no-match (protocol-critical)
internal/infrastructure/db/postgres/sqlc/query.sql:414 / sqlite/sqlc/query.sql:422
The :exec UPDATE returns no error when 0 rows are affected. A backfill run that hits a non-existent txid counts the entry as res.Signed++ in backfill.go:131 while persisting nothing. An operator seeing signed=N failed=0 would believe forfeits are broadcast-ready when they are not. This is the blocker for merging — unaddressed since the last review.
Fix still needed: Change to :execresult, check RowsAffected() == 0, return an error.
2. 🟡 Still open — GetAllVtxos full-set memory load (backfill.go:61)
No filtered query or documentation added. Still loads the entire vtxo set to filter at the application layer.
3. 🟡 Still open — SQLite concurrent-access hazard (cmd/arkd-forfeit-backfill/main.go)
No operational warning added to the tool's help text or README. Operators running this against a live SQLite deployment risk database is locked errors under concurrent round finalization.
4. 🟡 Still open — No ctx.Done() check in backfill loop (backfill.go:77-132)
Context cancellation (e.g., SIGINT) is not checked between iterations. Large backfills cannot be interrupted cleanly.
5. ℹ️ Still open — findForfeitTx duplication (backfill.go:145 / fraud.go)
No cross-reference comment added. The two implementations can silently diverge.
No new findings from the incremental diff.
is your comment on this issue out of date? |
is your comment on this issue out of date? |
|
Yes, that finding is out of date for the current version of this PR. My original review flagged The PR has since been substantially reworked. The current head (
The silent- |
Summary
Today arkd stores forfeit transactions carrying only the user's signature and adds the operator's signature lazily, at fraud‑broadcast time (
broadcastForfeitTx,fraud.go:167). If the operator's signer were unavailable when fraud must be punished, the forfeit could not be broadcast. (The doc comment atfraud.go:29already claims forfeits are "created and signed during the batch execution" — this PR makes that true.)This PR makes forfeit txs broadcast-ready at rest: arkd signs each forfeit at collection time, so every future forfeit is stored operator-signed.
Nothing has to be migrated.
broadcastForfeitTxdecides per forfeit whether the stored PSBT already carries every signature it needs and signs only when it does not, so forfeits written before this change keep working untouched. There is no on-boot migration, no migration-status table and no tool to run.A standalone backfill was part of an earlier revision and has been dropped on review, along with the
RoundRepository.PatchForfeitTxsmethod andConfig.RepoManager()accessor that existed only to serve it. It is kept in a gist should anyone ever want to patch stored forfeits by hand; the gist also carries the oneUPDATEit performed.Changes
1. Sign forfeits on collection (
internal/core/application)signForfeitTxshelper signs each collected forfeit PSBT viasigner.SignTransactionTapscriptin the finalization path, before persisting.2. Don't double-sign at fraud time (
internal/core/application/fraud.go)TestReactToFraudin e2e.broadcastForfeitTxnow signs only when a signature is still missing, viadomain.ForfeitTxReadyToBroadcast. Pre-signed forfeits are finalized directly.3. Reject forfeits that arrive carrying operator signatures (
service.go)VerifyForfeitTxsonly assertslen(TaprootScriptSpendSig) > 0on the vtxo input, and its txid rebuild ignores witness data, so a client could submit a forfeit carrying aTaprootScriptSpendSigunder the operator's x-only key over the real leaf. Both the key and the leaf are known to the client.signForfeitTxswould fail the whole round for every participant — with no conviction recorded against the submitter.SubmitForfeitTxsnow rejects a forfeit carrying an operator-key tapscript sig, or any key spend sig (the connector is a wallet output a client cannot sign). It fails closed when no operator key is configured, sinceSettings.SignerPubkeyis nillable and an empty key set would compare against nothing.Test plan
TDD throughout (test written and watched fail before each implementation).
signForfeitTxs(signs each forfeit, propagates signer errors); the submission guard (rejects planted operator/key-spend sigs, fails closed with no operator key);ForfeitTxReadyToBroadcast(ready only when every leaf pubkey has signed and the connector is signed, still ready when the signing key is no longer current);.TestReactToFraudis the targeted guard: it settles, forfeits, unrolls the forfeited vtxo on-chain, and asserts the server broadcasts the (now pre-signed) forfeit and claims the vtxo — and is what surfaced the duplicate-key bug fixed in change addBIP68function #2.TestEagerForfeitSurvivesWalletRotationsettles, forfeits, rotates to a new signer key with no deprecated key retained, then unrolls the forfeited vtxo and asserts the server still punishes the fraud.Local verification
db(sqlite+postgres), no race: ✅db(sqlite) + all other./internal/...packages under-race: ✅ (no data races)internal/core/application: ✅go vet: ✅;golangci-lint: ✅TestReactToFraud✅,TestEagerForfeitSurvivesWalletRotation✅. During the fraud window the signer records zeroSignTransactionTapscriptcalls, against one before change addBIP68function #2, confirming the stored forfeit is broadcast without the signer.Notes for reviewers
internal/core/domainnow importsbtcutil/psbtfor the first time, for the shared readiness helper. If that layer should stay clear of transaction encoding,pkg/ark-lib/scriptnext toFinalizeVtxoScriptis the natural home, at the cost of a cross-module change.SubmitForfeitTxsnow reads settings on every call and rejects if they are unavailable — a new failure mode on a hot path.Summary by CodeRabbit
Release Notes
New Features
Improvements / Bug Fixes
Infrastructure