Skip to content

live-store: owner-tagged atomic single-spend domain (off-chain + on-chain), part of #1159 - #1162

Open
bitcoin-coder-bob wants to merge 22 commits into
masterfrom
bob/onchain-arkade-single-spend-claim
Open

bitcoin-coder-bob wants to merge 22 commits into
masterfrom
bob/onchain-arkade-single-spend-claim

Conversation

@bitcoin-coder-bob

@bitcoin-coder-bob bitcoin-coder-bob commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Part of the M3 (on-chain co-sign) milestone of #1159:

  1. Adds ClaimOutpoints/ReleaseOutpoints (atomic claim over the shared spent-input set).
  2. Owner-tags the whole single-spend domain and makes the off-chain writer atomic too, unifying off-chain and on-chain single-spend into one atomic conflict domain across processes.
  3. Merges master. Latest merge also picks up the btcd v0.26 sub-module migration (Migrate to btcd v0.26 and the v2 sub-modules #1173).
  4. Wires the claim into RegisterIntent, retiring offchainTxMu entirely.

What and why

The off-chain writer was non-atomic cross-process: redis Add did a separate SIsMember then SAdd under a per-process mutex, so on redis with >1 arkd an off-chain checkpoint spend and an on-chain cosign of the same vtxo could both succeed. Two hazards a naive fix would hit are handled below.

  • The input set is now owner-tagged: outpoint -> owning arkTxid. inmemory map[string]string; redis SET -> HASH (new key).
  • Both writers (Add, ClaimOutpoints) go through one owner-tagged compare-and-set (redis Lua / inmemory single lock): a different owner conflicts, the same owner is idempotent.
  • Add returns a tri-state (ClaimFresh / ClaimAlreadyOwned / ClaimConflict).
  • Release/Remove are owner-scoped, so one tx cannot drop another's protection (proven: the owner-tagged claim forbids two owners holding the same outpoint, and the delete guards on value == owner).
  • SubmitOffchainTx deletes offchainTxMu and its recheck loop, the atomic store is now the sole cross-process barrier.
  • On every start the redis store rebuilds the inputs hash from the stored tx bodies. That is the migration from the old untagged SET: an off-chain tx accepted before the upgrade keeps its protection across the switch. Details in the ops section.

The two hazards, and how they're handled

  • Hazard 1 (registration set): spentVtxoKeys is only TxIn[0] of each checkpoint tx, but Add registers every TxIn. Preserved exactly, the claim keys off the checkpoint TxIn set (unchanged from the original loop), never spentVtxoKeys, so it's a strict superset and nothing narrows. (Multi-input checkpoints are currently unreachable via the rebuild gate, but the code handles them by construction.)
  • Hazard 2 (retry false-reject / double-accept): without an owner tag, an atomic reject-if-present Add would false-reject a legitimate same-arkTxid retry (async projection lag lets it re-reach Add). The owner tag makes the same owner idempotent. And deleting the mutex would newly let a same-process concurrent same-txid submit append two OffchainTxAccepted events, so on ClaimAlreadyOwned the caller returns the accepted result without appending a second accept event, restoring the single-accept invariant.

Why redis Lua scripts, and a request to reviewers

Please review the four Lua scripts in internal/infrastructure/live-store/redis/offchain_txs.go line by line. I am not fluent in redis Lua scripting, I wrote these by reasoning from the redis docs and verified them against a live redis through the conformance suite, but I cannot vouch for them the way I can for the Go code. Treat them as the least-reviewed part of this PR.

Why scripts rather than the WATCH + TxPipelined pattern the other redis stores use:

  • The claim is a decision made atomically with a write. Add has to return fresh, already-owned or conflict at the same instant it registers the inputs. With WATCH the owners are read client-side, the decision is made, and EXEC fails if anyone touched the key in between, so the loser retries, re-reads, and only then sees the conflict. A script decides and writes in one server-side step and answers every caller in one round trip.
  • WATCH turns contention into retries, and the store's retry loops treat every error as retryable, so a conflict could burn the whole retry budget and surface as an internal error instead of a clean conflict. That is why the "exactly one of 64 concurrent claimers wins" test is a clean assertion here.
  • There is precedent in this repo: confirmScript in confirmation_sessions.go already uses a Lua script for the same reason, a conditional write that depends on a read.

What the scripts cost, so reviewers can weigh it:

  • The logic lives in a string with no Go type checking, and it can only be exercised against a live redis. TestLiveStoreImplementations/redis/OffChainTxStore covers it.
  • Scripts block the server while they run. These loop over a handful of outpoints, so it is microseconds, and they must stay that small.
  • addScript and removeScript touch two keys with no hash tag, which Redis Cluster rejects. The old WATCH across the same two keys had the identical limitation, and the store uses a single-node client, so nothing regressed.
  • claimScript and addScript duplicate the two-pass conflict check. They could be one script with the tx body as an optional argument. Open to doing that if a reviewer prefers it.

Things worth checking in the scripts specifically: that a missing hash field compares as Lua false and not nil in both the conflict pass and the anyNew pass, that a conflict returns before any HSET so nothing is partially registered, that the already-owned path in addScript is the intended behaviour when it still overwrites the tx body, and that the {status, conflict} reply shape matches what parseClaimResult expects for all three outcomes.

Claim lifecycle in RegisterIntent (please scrutinise this)

RegisterIntent previously checked Includes under offchainTxMu. It now claims its vtxo inputs through ClaimOutpoints, which is atomic across processes rather than within one, and reserves rather than only checking, so it also stops two intents registering the same vtxo. offchainTxMu is therefore gone.

Reserving carries an obligation the read-only check did not: a claim never released leaves the vtxo permanently unspendable, and on redis that survives a restart. It fails silently, no error is raised anywhere, the vtxo simply stops being spendable. Every path that drops an intent releases: round start reconciles from the popped set (below), delete by intent proof, admin delete and delete-all (which deletes exactly the snapshot it released, by id, so an intent registered in between keeps its claim and the selected-intent set the reconcile reads is never wiped), plus a failed Intents().Push so a rejected registration leaves nothing behind. Intents still queued for a later round deliberately keep their claims.

Round start releases the claims of every intent the last Pop selected that is no longer queued. A popped intent ends up registered on the round, re-pushed to the queue, or dropped (spent boarding input, liquidity abort, failed re-push), and only a re-pushed one is still queued under the same id. Reconciling from the popped set covers all of those in one place and mirrors how DeleteVtxos already reconciles the intent vtxo index at the same point. Both backends persist the selected set, so it also covers a crash between Pop and the round upsert on redis. An earlier revision released only the round's registered intents, which leaked the claims of dropped ones.

One assumption holds this together, not enforced by the type system:

  • Every registered intent is eventually selected. Pop skips intents with no receivers, which RegisterIntent cannot currently produce because Intent.validate rejects an empty output set. Making receivers optional (see the commented-out IntentStore.Update) would reintroduce the leak. The claim site carries this warning in a comment.

For ops: upgrade, rollback, and what to watch

What changes in redis (inmemory backend: nothing, a restart clears it)

  • New key offChainTxStore:inputsByOwner, a HASH. Field is the outpoint as txid:vout, value is the owner: a 64-hex ark txid for an in-flight off-chain spend, or a UUID intent id for a queued intent.
  • The old key offChainTxStore:inputs (a SET) is no longer read or written. It has no TTL, so it lingers until deleted.
  • offChainTxStore:txs is unchanged.
  • Entries in the new hash have no TTL. They are removed when an accepted off-chain tx is projected to the DB, and when an intent leaves the queue (round start, delete by proof, admin DeleteIntents). They persist across restarts, exactly as the old SET did.
  • On every start the store rebuilds the hash from offChainTxStore:txs. Each stored body's checkpoint inputs are re-registered under its ark txid with HSETNX, so an existing owner is never overwritten, and through a script that first checks the body still exists, so a Remove by another instance in between cannot leave inputs registered that nothing could clear. This is the migration from the old SET, and it also restores what a rollback runbook deleted. It logs re-registered N spent input(s) from M stored offchain tx(s) at info level when it added anything. If it cannot complete, arkd refuses to start with failed to init redis live store: failed to rebuild offchain tx inputs from stored txs, because a store serving after a partial rebuild would answer fresh for the inputs of every in-flight tx it did not reach. It has two minutes, and that cap is fatal rather than degrading: on a large backlog over a slow redis link the process exits instead of serving unprotected, which is deliberate but worth knowing on call. A stored tx whose checkpoint txs do not all parse is skipped as a whole with a warning, never registered in part.

Upgrade

  1. Stop all, then start all. The rebuild at start re-registers every in-flight off-chain tx, so nothing is lost across the restart. Old and new instances still cannot see each other's claims (old writes the SET, new writes the HASH), so a rolling restart leaves a mixed-version window in which the same vtxo can be accepted once by each side.
  2. If a rolling restart is unavoidable, block SubmitOffchainTx and RegisterIntent at the edge until the last old instance is gone. A tx an old instance accepts during the roll only reaches the new hash when a new instance next starts.
  3. Intents already queued at upgrade time hold no claim. They stay covered by the pre-existing intents index check in SubmitOffchainTx until they settle. No action needed. Upgrading between rounds with an empty queue sidesteps this.
  4. Once every instance runs the new version: DEL offChainTxStore:inputs. Cosmetic.

Rollback

  • Same mixed-version caveat in reverse: stop all, start old. The old version ignores the new hash.
  • Before rolling forward again: DEL offChainTxStore:inputsByOwner while no new-version instance runs. Claims taken before the rollback, for txs and intents that then settled under the old version, would otherwise block those vtxos. The rebuild at the next start puts back the entries that belong to still in-flight txs, so the delete only sheds stale ones.

What to watch

  • HLEN offChainTxStore:inputsByOwner. It should track the number of queued intent vtxo inputs plus in-flight off-chain spends. Growth that does not fall back after rounds complete means a claim leak.
  • A stale claim surfaces to clients as VTXO_ALREADY_SPENT for a vtxo the DB shows unspent: message ... already spent from the off-chain path, ... is currently being spent from intent registration.
  • Warn-level log lines, each meaning a claim may have been left behind: failed to release conflict-domain claims of intent, failed to get selected intents to release their claims, failed to view intents ... to release their claims.
  • A failed claim fails closed: the client gets INTERNAL_ERROR and nothing is accepted unprotected. The intent path logs failed to claim intent inputs in the conflict domain at error level, the off-chain path has no log line. In practice this means redis is unreachable.
  • After an arkd crash between accepting an off-chain tx and its DB projection, the entry stays. The client's retry is rejected by the pre-claim Includes check, so it needs the manual clear below. Same as with the old SET.

Clearing a stuck vtxo

HGET offChainTxStore:inputsByOwner <txid>:<vout>
  • Owner is an ark txid: HGET offChainTxStore:txs <arkTxid> shows the in-flight tx. If the DB already has that tx accepted or finalized, or has no record of it at all, the entry is stale. Clear both: HDEL offChainTxStore:inputsByOwner <txid>:<vout> and HDEL offChainTxStore:txs <arkTxid>.
  • Owner is a UUID: admin ListIntents shows whether it is still queued. If it is, admin DeleteIntents releases it properly. If it is not, HDEL offChainTxStore:inputsByOwner <txid>:<vout>.
  • Only delete once the owner is confirmed not in flight. Removing a live claim reopens the double-spend window for that vtxo.

Merge ordering

Merge this one last of the three open #1159 PRs (#1160, #1161, #1162), timed with a deploy, since it carries the redis key change and the stop-all upgrade sequence in the ops section above. There is no code dependency on the other two and no conflict with either: #1161 shares no files with this PR, and #1160's service.go hunks sit in a different part of SubmitOffchainTx (a trial merge is clean). #1161 goes first because of its migration timestamp, see its description. If #1160 merges before this, merge master here again before reading CI.

Test plan

  • go build ./..., make lint (0 issues), gofmt clean
  • conformance (TestLiveStoreImplementations) under -race on both inmemory and redis: Add tri-state (fresh then already-owned), cross-domain conflict, owner-scoped release, all-or-nothing batch conflict, re-claim after release, exactly one of 64 concurrent distinct-owner claimers wins, 64 same-owner claimers never conflict. The redis Lua path runs all of these against a live redis, including the concurrency cases.
  • full internal/core/application + internal/interface suites green (accept path changed)
  • claim-release coverage: the round-start reconcile (dropped intents release, a re-pushed one keeps its claim), delete by proof end to end through the note-proof fixture, admin delete and delete-all, an intent registered between the delete-all snapshot and the delete is kept with its claim, a failed lookup fails the delete without deleting, boarding-only no-op, lookup failure. One case drives the service and the admin service and asserts they release identically, since both go through the same helper and a divergence there would leak claims silently; it fails if either path is changed alone. The failed-push release in RegisterIntent needs a real vtxo input through full validation and is covered by review only
  • TestOffChainTxStore on a live redis: rebuilds from a body stored without an owner entry (then a different owner conflicts and the tx itself is idempotent), never overwrites an existing owner, skips a malformed body, no-op when empty, does not re-register the inputs of a body removed meanwhile, skips a tx with one malformed checkpoint tx as a whole, fails to construct when the rebuild cannot run, and runs a script at least once with zero retries. The first and third fail with the rebuild disabled, the removed-meanwhile case fails with the body check dropped from the script, the whole-tx skip fails with partial parsing restored
  • conformance on both backends: Add rejects a tx with a malformed checkpoint tx and stores nothing
  • -race on the full live-store

Draft

Foundational slice of the #1159 co-sign milestone; kept draft pending the rest of M3 (ark-lib builder, guarded cosign method that also checks Intents().IncludesAny, anchor+CPFP broadcast) and team review of the rollout requirement.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed intent deletion so associated VTXOs are released correctly.
    • Prevented deletion from affecting intents registered after deletion begins.
    • Improved cleanup when intents are removed, dropped, or fail to enter the queue.
    • Prevented malformed transaction data from being partially processed or stored.
  • Reliability

    • Added conflict detection for transactions and intents claiming the same inputs.
    • Improved concurrent submissions, retries, and duplicate-ownership handling.
    • Improved recovery of transaction claims after service restarts.
    • Live-store initialization now reports recovery failures instead of continuing silently.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 04c953fc-2649-4367-824d-5097510ad9dd

📥 Commits

Reviewing files that changed from the base of the PR and between 1058a24 and b0ca2a6.

📒 Files selected for processing (7)
  • internal/core/application/intent_claims.go
  • internal/core/application/service.go
  • internal/core/ports/live_store.go
  • internal/infrastructure/live-store/inmemory/offchain_txs.go
  • internal/infrastructure/live-store/live_store_test.go
  • internal/infrastructure/live-store/redis/offchain_txs.go
  • internal/test/e2e/e2e_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • internal/infrastructure/live-store/live_store_test.go
  • internal/core/ports/live_store.go
  • internal/infrastructure/live-store/inmemory/offchain_txs.go
  • internal/core/application/service.go
  • internal/core/application/intent_claims.go
  • internal/infrastructure/live-store/redis/offchain_txs.go
  • internal/test/e2e/e2e_test.go

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


Walkthrough

The change replaces local off-chain transaction locking with owner-scoped atomic input claims. It updates in-memory and Redis stores, rebuilds Redis claims at startup, integrates claim release into intent lifecycle operations, and expands application, integration, and end-to-end coverage.

Changes

Off-chain claim lifecycle

Layer / File(s) Summary
Claim contract and in-memory implementation
internal/core/ports/live_store.go, internal/infrastructure/live-store/inmemory/offchain_txs.go, internal/infrastructure/live-store/live_store_test.go
The store now returns claim statuses and conflict outpoints. It supports owner-scoped claim and release operations. Malformed checkpoint inputs fail without partial updates.
Redis claim storage and rebuild
internal/infrastructure/live-store/redis/offchain_txs.go, internal/infrastructure/live-store/redis/offchain_txs_test.go, internal/infrastructure/live-store/redis/store.go
Redis stores input owners in a hash and uses Lua scripts for atomic operations. Startup rebuilds input ownership from stored transaction bodies and returns initialization errors.
Application claim and release integration
internal/core/application/service.go, internal/core/application/admin.go, internal/core/application/intent_claims.go, internal/config/config.go, internal/core/application/script_watch_test.go
Submission and intent registration claim inputs through the shared store. Intent cleanup releases claims during deletion and round reconciliation. Admin deletion uses a snapshot and deletes only snapshot ids.
Intent claim lifecycle validation
internal/core/application/intent_claims_test.go, internal/test/e2e/e2e_test.go
Tests cover claim release paths, lookup failures, snapshot behavior, atomic store operations, and concurrent submissions with distinct destinations.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SubmissionService
  participant VTXORepository
  participant OffChainTxStore
  participant IntentStore
  SubmissionService->>VTXORepository: Refresh spent VTXO state
  VTXORepository-->>SubmissionService: Return current input status
  SubmissionService->>OffChainTxStore: Atomically claim inputs
  OffChainTxStore-->>SubmissionService: Return claim status and conflict outpoint
  SubmissionService->>IntentStore: Queue accepted intent
  IntentStore-->>SubmissionService: Return insertion result
  SubmissionService->>OffChainTxStore: Release claims during cleanup
Loading

Suggested reviewers: arkana-ai-bot

Merge Risk: 🟡 Moderate · up to b0ca2

This change adds shared atomic input claims for off-chain and on-chain spending, but unresolved compilation and claim-lifecycle failure paths could prevent validation or allow duplicate/conflicting spends or stuck funds. Resolve these paths before merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: an owner-tagged atomic single-spend domain for off-chain and on-chain activity. The issue reference is supplementary but does not make the title unclea…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/onchain-arkade-single-spend-claim

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.

@bitcoin-coder-bob bitcoin-coder-bob changed the title live-store: atomic ClaimOutpoints/ReleaseOutpoints for on-chain single-spend (part of #1159) live-store: owner-tagged atomic single-spend domain (off-chain + on-chain), part of #1159 Jul 27, 2026
…tore

Adds a claim primitive over the same spent-input set that Add/Includes/Remove
use, for spends that carry no checkpoint txs (on-chain Arkade cosigns, #1159).
ClaimOutpoints registers a batch of outpoints only if none is already present,
returning the first conflicting one otherwise; ReleaseOutpoints removes them.

- inmemory: two passes under one write lock, so the claim is all-or-nothing
  and atomic within the process.
- redis: a single Lua check-and-set, so concurrent on-chain claims are atomic
  across processes (the separate SIsMember+SAdd path is not).

Includes is unchanged and remains the shared read, so an on-chain claim is
visible to the off-chain guard and vice-versa. Scope limit worth stating: the
off-chain Add writer is still a non-atomic check-then-set, so cross-process
mutual exclusion between the off-chain and on-chain writers is not yet
guaranteed; that, plus also consulting the Intents domain, is required before
the cosign path ships. Purely additive: the new methods are unused until then.

Part of #1159.
…ter atomic

Unifies off-chain and on-chain single-spend into ONE atomic conflict domain
across processes (#1159). The input set becomes owner-tagged (outpoint ->
owning arkTxid): inmemory map[string]string, redis SET -> HASH via a new key.
Both writers go through one owner-tagged compare-and-set (redis Lua, inmemory
single lock): a different owner conflicts, the same owner is idempotent.

- Add returns a tri-state (ClaimFresh / ClaimAlreadyOwned / ClaimConflict) and
  claims every checkpoint TxIn (unchanged registration set, so the multi-input
  case is preserved exactly, not narrowed to TxIn[0]).
- ClaimOutpoints/ReleaseOutpoints gain an owner param; Release/Remove are
  owner-scoped so one tx cannot drop another's protection.
- SubmitOffchainTx: delete offchainTxMu and its recheck loop; the atomic store
  is now the sole cross-process barrier. On ClaimAlreadyOwned (a concurrent or
  retried same-arkTxid submit) it returns the accepted result without appending
  a second OffchainTxAccepted event, preserving the single-accept invariant.

Rollout note: the redis key changes type (SET -> HASH), so it uses a new key
name to avoid WRONGTYPE on a mixed-version deploy. Off-chain submissions should
be quiesced across the upgrade window, since an in-flight tx loses its input
protection at the instant of the version switch.

Part of #1159.
The concurrent submit txs fixture listed 7 destination pkscripts, but three
of them were byte-identical copies of the same value, so only 5 were really
distinct. That was a fixture slip in a hand-edited hex block.

The ark txid is computed over the unsigned tx, and signatures live in the
taproot witness, so a shared destination pkscript over the same vtxo input
produces the same unsigned tx and therefore the same arkTxid. Under the
owner-tagged single-spend domain a same-arkTxid resubmission is an
idempotent re-claim, not a conflict, so those duplicates returned success
instead of VTXO_ALREADY_SPENT and the success count came out as 2 or 3
depending on the backend. No double spend occurred, one txid means one
spend, the fixture simply was not testing distinct double spends.

Derive the destinations from freshly generated keys via script.P2TRScript
so all 7 submissions are genuinely different ark txs contending for one
vtxo. The assertions are unchanged, still exactly 1 success and
len(destinations)-1 errors.
@bitcoin-coder-bob
bitcoin-coder-bob force-pushed the bob/onchain-arkade-single-spend-claim branch from 6f15678 to ad348b9 Compare July 30, 2026 14:41
@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 3+ days without review. @bitcoin-coder-bob is anyone looking at this?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR has been open for 9 days without review. @bitcoin-coder-bob is anyone looking at this? (Part of the #1159 VTXO live-store stack — #1160 and #1161 also awaiting review.)

@arkana-ai-bot

Copy link
Copy Markdown

This PR (and the stack #1160#1161#1162) has been open for 19+ days without review. @bitcoin-coder-bob is anyone looking at this?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — #1162

live-store: owner-tagged atomic single-spend domain

Core change

Replaces the per-process offchainTxMu mutex + recheck-then-add pattern with an owner-tagged atomic claim at the store layer. The inmemory implementation uses its own write lock; the Redis implementation uses Lua scripts, making the conflict check + registration a single server-side atomic step that is safe across multiple arkd processes.

Lua scripts

The scripts are correct:

  • claimScript and addScript check all outpoints for different-owner conflicts first (returns immediately without registering anything on conflict), then check if the owner already holds all of them, then register the batch. The two-pass structure is correct for Lua atomicity: since the script runs atomically on Redis, no interleaving between the conflict check and the HSET is possible.
  • removeScript and releaseScript only delete outpoints whose owner matches — correct scoped deletion.
  • The anyNew logic correctly distinguishes ClaimAlreadyOwned (all already owned by same owner) from ClaimFresh (at least one new).

Key rename

offChainInputsSetKey (Redis SET) → offChainInputsHashKey (Redis HASH) with a new key name. This avoids a WRONGTYPE error on a mixed-version rolling deploy since the old SET is left intact until it expires. Well handled.

ClaimAlreadyOwned (idempotent retry)

When Add returns ClaimAlreadyOwned, the service skips applying the Accepted event (changes = nil) and returns the accepted result. This correctly handles a concurrent or retried submit of the same arkTxid without emitting a duplicate event.

ClaimOutpoints / ReleaseOutpoints

New methods for the on-chain Arkade cosign path (#1159). The same conflict domain — good, the mutual exclusion is enforced at the store layer regardless of the path.

Notes

  • The inmemory Remove previously deleted inputs unconditionally; it now only deletes inputs owned by the arkTxid. This is stricter and correct.
  • The Redis Remove loads the tx first to get inputs — if the tx is absent (already removed), it still calls removeScript with no outpoints (no-op). Safe.
  • parseClaimResult parsing from the Lua return array was not shown; worth confirming the error handling is correct for unexpected return shapes.

⚠️ Protocol-critical — touches the single-spend enforcement path for offchain VTXOs. Flagged for human review regardless.

Verdict: Design is sound, atomicity is correct, and the idempotent-retry case is well handled. Looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — owner-tagged atomic single-spend domain

🔒 PROTOCOL-CRITICAL — please ensure a second human reviews before merging. This touches the core double-spend prevention for off-chain transactions.

What this does: Replaces a per-process mutex + read-check-then-add pattern in SubmitOffchainTx with a single atomic owner-tagged claim step. The new OffChainTxStore.Add returns a ClaimStatus tri-state (Fresh/AlreadyOwned/Conflict) and accepts no external lock. The same conflict domain is extended for on-chain spends via ClaimOutpoints/ReleaseOutpoints.

Design correctness:

  • The old pattern had a TOCTOU window: read (Includes), release lock, concurrent submit claims the same VTXO. The new atomic claim eliminates that window.
  • In-memory backend: the sync.RWMutex wrapping the map makes the claim atomic at the process level. ✓
  • Redis backend: a Lua script (not shown in diff, but referenced via addScript, claimScript, etc.) makes the claim atomic across processes. The design note in the port interface makes this explicit. Assuming the Lua script itself is correct, the approach is sound.
  • ClaimAlreadyOwned returning the accepted result without re-applying the event is correct idempotency for same-arkTxid retries.
  • Removing offchainTxMu from the service struct is safe given the store is now the single authority — make sure no other call site depended on that mutex outside SubmitOffchainTx (the diff removes it from test structs as well, which suggests it was only used here).

Things to verify before merging:

  1. The Lua scripts (addScript, claimScript, removeScript, releaseScript) — not in this diff but critical to correctness. Ensure they are also reviewed.
  2. Confirm Remove (called on finalization) correctly releases inputs so they don't permanently block the outpoints.
  3. The ClaimAlreadyOwned path sets changes = nil, which drops any new event. Verify there are no side effects from the earlier part of SubmitOffchainTx that need reverting when we take this path.

Test coverage: The e2e double-spend test is updated with distinct randomized destinations (fixing a pre-existing ambiguity where same-destination txids would be identical). Good improvement.

Verdict: Approach is sound and a clear security improvement over the mutex pattern. Human review of the Lua scripts and the change = nil path is essential before merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR has been open 28 days without review. @bitcoin-coder-bob is anyone looking at the single-spend domain stack (#1159)?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha ad348b9

This PR converts OffChainTxStore.Add from a plain write to an owner-tagged atomic claim, and adds ClaimOutpoints/ReleaseOutpoints for on-chain Arkade spends. It also removes the per-process offchainTxMu mutex in SubmitOffchainTx in favour of the store's atomic barrier.

Correctness of the core atomic claim logic:
The inmemory claimLocked function is a clean single-lock implementation:

  1. First pass: reject if any outpoint is held by a different owner (returns ClaimConflict and the conflicting outpoint, registers nothing).
  2. After conflict pass: if all present outpoints are owned by owner, check for any new ones. If none, ClaimAlreadyOwned. Otherwise register all and ClaimFresh.

This is correct and all-or-nothing within the lock.

SubmitOffchainTx service change:

  • Removes the explicit re-check loop (was prone to TOCTOU between process-local lock and the store) and the offchainTxMu mutex.
  • ClaimAlreadyOwned path correctly clears changes to avoid emitting a duplicate Accepted event — good.
  • ClaimConflict returns the conflicting outpoint in the error, which is an improvement for diagnostics.

One question: The Remove path now only deletes inputs owned by this arkTxid. If a concurrent Add by a different owner races with Remove (e.g. after a partial round failure), is there a scenario where inputs stay claimed beyond their intended lifetime? Specifically: can ClaimAlreadyOwned on a re-submit prevent the original owner's Remove from cleaning up? Looking at the logic: Remove deletes only entries where owner == arkTxid, so a different-owner conflict would never be cleaned up by the original owner's Remove. But by design, a conflict returns ClaimConflict and the new owner never gets registered — so there's no cross-owner input left dangling. This looks correct.

Test coverage is good: disjoint claim, idempotent re-claim, all-or-nothing conflict, owner-scoped release, and a concurrent stress test.

Looks ready. Not protocol-critical in the tree-signing sense, but double-spend prevention is critical.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

First review — #1162 (owner-tagged atomic single-spend domain)

This PR introduces an important architectural change: the OffChainTxStore becomes the canonical cross-process single-spend conflict domain, shared by both off-chain and on-chain (Arkade) spends, with owner-tagged outpoint registration.

Design assessment:

  • The ClaimStatus enum (ClaimFresh / ClaimAlreadyOwned / ClaimConflict) is a clear, well-named three-way outcome. The semantics are well-documented in the interface comment.
  • Moving from a per-process offchainTxMu + recheck pattern to an atomic store operation (Redis Lua / in-memory lock) is the correct progression — it's the only option that works across processes.
  • ClaimAlreadyOwned (idempotent retry by the same owner) is handled in SubmitOffchainTx by setting changes = nil so no duplicate Accepted event is written. This is correct.
  • The new ClaimOutpoints / ReleaseOutpoints methods for on-chain Arkade UTXOs (issue #1159) extend the same domain cleanly.

Questions / observations:

  1. The in-memory store's snapshot in Get (maps.Clone) is a good race fix, but SigningContext.AggregatedNonces is a slice and isn't cloned. If callers mutate it concurrently, there's still a data race — worth checking if that path exists.
  2. When ReleaseOutpoints is called for an owner, outpoints held by a different owner are left untouched — this is the documented behaviour. Confirm that the on-chain flow that calls this never races with an off-chain claim of the same outpoint in a way that could release the wrong owner's claim.
  3. The PR description mentions this is part of #1159 — dependent PRs (#1161, #1160) should be reviewed in sequence before this lands.

Overall the design is sound. The atomic store semantics are correct. ⚠️ PROTOCOL-CRITICAL — flagging for human sign-off before merge.

…single-spend-claim

Two conflicts in service.go, both from master landing race fixes this branch had
not seen.

The struct: this branch removes offchainTxMu, master adds feeBumpMtx. Both are
taken, but offchainTxMu is restored rather than dropped, because master also
added a second use of it in RegisterIntent (ArkLabsHQ/arkd#62) guarding the
conflict-domain check against a concurrent SubmitOffchainTx. Dropping the field
would have silently deleted that fix. The owner-tagged claim replaces it in the
next commit.

SubmitOffchainTx: master's block is the offchainTxMu recheck-then-add that the
atomic claim already replaces, so that part is dropped, but it also carries the
DB re-read from ArkLabsHQ/arkd#71, which the claim does not replace. An accepted
offchain tx is evicted from the cache once projected, so the earlier read can be
stale and the claim has nothing left to conflict against. That re-read is kept.
…TxMu

RegisterIntent now claims its vtxo inputs through ClaimOutpoints instead of
checking Includes under offchainTxMu. The claim is atomic across processes, as
Add is, where the mutex only serialised one process, and it reserves rather than
only checking, so it also stops two intents registering the same vtxo. That makes
offchainTxMu redundant and it is removed.

Reserving creates an obligation the old read-only check did not have: a claim
that is never released leaves the vtxo permanently unspendable, and on redis that
survives a restart. Every path that drops an intent now releases its claims:
round completion, where the round carries the intents Pop selected; delete by
intent proof; admin delete and delete-all; and a failed Intents().Push, so a
rejected registration leaves nothing behind. Intents still queued for a later
round keep their claims, which is what we want.

Release is per-owner and idempotent, so releasing a claim already gone, or one
since taken by someone else, is a no-op. Failures are logged rather than
returned, since every caller is dropping the intent regardless.

This rests on every registered intent eventually being selected by Pop. Pop skips
intents with no receivers, which RegisterIntent cannot produce because
Intent.validate rejects an empty output set. The claim site says so, since making
receivers optional would silently reintroduce the leak.
…single-spend-claim

No conflicts. Picks up the trivy scan fix (#1172), which this branch needs for a
green Build and Scan: the pull_request workflow builds a merge commit, so a fresh
run inherits master's no-cache-filters, but re-running the existing run reuses the
old merge commit and keeps failing.
@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open 32+ days without a formal review. @bitcoin-coder-bob — is this in the review queue?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — #1162 (sha bf4c653, updated from ad348b9)

live-store: owner-tagged atomic single-spend domain

Protocol-critical — changes the SubmitOffchainTx double-spend barrier. Human sign-off required.

Key changes since sha ad348b9

  • offchainTxMu per-process mutex removed from service struct
  • SubmitOffchainTx drops the per-process recheck-under-mutex pattern; the store's Add() is now the single barrier
  • Add() returns a status enum: ClaimFresh, ClaimConflict, ClaimAlreadyOwned
  • Retry path for idempotent submits: ClaimAlreadyOwned returns success without re-emitting Accepted
  • releaseClaimsOfIntents / releaseClaimsOfIntentIds added; DeleteIntents now calls release before delete
  • Comprehensive test in intent_claims_test.go

Looks correct

  • The claim tag being the intent/tx owner id (not global) is the right design: cross-owner release would clobber valid claims
  • ClaimAlreadyOwned path sets changes = nil before returning — no duplicate event in projections
  • The "re-read before claiming" comment and reference to ArkLabsHQ/arkd#71 give good context
  • Tests verify per-owner keying, lookup-by-id path, boarding-only intent (no inputs = no claims), lookup failure isolation, and delete-all claim release

One concern to verify

The script_watch_test.go change removes offchainTxMu: &sync.Mutex{} from service initialization. Confirm no other test or code path in the file initializes service with a mutex expectation that would now panic or dereference nil.

Question

In the ClaimConflict branch, conflict.String() is used in the error — does this always produce a useful outpoint string, or can it return an empty/zero value for non-vtxo input types (e.g. boarding inputs)?

Verdict: Looks ready. The atomicity move from per-process mutex to store-level claim is the right architectural direction for a multi-process deployment.

@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

Answering the two items from the last pass, plus what I found on the red check.

1. offchainTxMu removal, no path left expecting it

Verified. The only occurrence anywhere on the branch is inside a comment at internal/core/application/service.go:2302, describing what the claim replaced. The struct field and every initialization of it are gone, including the one in script_watch_test.go, so there is nothing left to dereference.

2. conflict.String() on a ClaimConflict

The conflict is a typed *domain.Outpoint, not a bare string, so it always renders as a real txid and index.

Boarding inputs cannot appear here. Claims are only ever taken over vtxo inputs, which is why a boarding-only intent takes no claims at all and is covered by its own test. So there is no input type that can reach this branch with an empty or zero value.

3. The postgres/redis failure is not from this PR

TestSweep/unrolled_batch fails identically on plain master:

timed out after 4m0s waiting for all vtxos to be swept; last error: 1 of 1 vtxo(s) not swept
--- FAIL: TestSweep (354.21s)
    --- FAIL: TestSweep/unrolled_batch (270.67s)

That is master at 13a33138 (run 33246517084), with none of this branch's code in it. Same test, same message.

Two things worth separating:

  • Master's integration CI is currently red. It was green through 7707f99a on 08-28 and failed at 13a33138 on 08-29, which is "Sign forfeit txs at collection-time" (Sign forfeit txs at collection-time #1110). The 08-31 run on 5e17913d failed earlier at go mod download with a proxy.golang.org stream error, so it never re-tested. Worth a look independently of this PR, since it blocks integration signal on everything open.
  • The suite is also independently flaky. Commit 8b34e352 ran ci_integration twice on 08-07, failing at 10:11 and passing at 19:41 with no code change.

So this branch is not the cause. It will stay red on that job until master is fixed, since merging current master brings the failure in.

Still open, and needs a human rather than a re-run

The rollout requirement in the PR description stands: the redis key changes type from SET to HASH, so it uses a new key name, and off-chain submissions need quiescing across the upgrade window or there is a brief interval where an in-flight tx keeps its body but loses its input protection. That needs ops sign-off, not just code review.

@bitcoin-coder-bob

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment.

I said master's integration CI was broken by #1110 and that this branch would stay red on postgres/redis until master was fixed. Both were wrong.

#1161 at b1e0e7bc6 contains #1110 and its postgres/redis job passed in 21m13s, running the same suite against the same master. So #1110 did not break TestSweep/unrolled_batch. The 08-29 master failure was an instance of the flake, not a regression, and I over-read a single failing run.

What still stands is the part that matters here: master at 13a33138 failed that identical test with none of this branch's code in it, so this branch is not the cause. The same-SHA evidence (8b34e352 failing then passing on 08-07 with no code change) points the same way.

I have merged current master into this branch (990ec3533) and CI is re-running. Locally the full live-store suite passes under -race, including the redis Lua path.

@arkana-ai-bot

Copy link
Copy Markdown

This PR (live-store owner-tagged single-spend domain) has been open for 5+ weeks without a review. @bitcoin-coder-bob — is this blocked on #1163 or another dependency?

Only intents registered on the round were released, so an intent Pop selected
and then dropped (spent boarding input, liquidity abort, failed re-push, crash
before the round was stored) kept its vtxos claimed forever. Reconcile from
the selected set instead: release every popped intent no longer queued.
@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 36+ days without review. @bitcoin-coder-bob the owner-tagged atomic single-spend domain PR is getting stale — is it blocked on anything?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 41+ days ago. @bitcoin-coder-bob need any help addressing the feedback on the live-store owner-tagged single-spend domain?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 41+ days ago. @bitcoin-coder-bob is this still in progress?

…budget

Add relies on a re-add of the same arkTxid carrying the same inputs: were a
changed set ever to arrive, the inputs only the previous body named would stay
claimed, because Remove finds a tx's inputs through the body it replaced. That
cannot happen, since an arkTxid is the txid of a tx committing to the checkpoint
outputs it spends, and the caller rebuilds and compares before calling. Written
down on the port so the next reader does not have to re-derive it.

The rebuild failing on its own deadline now says so, with the budget, instead of
surfacing a wrapped context error.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PROTOCOL-CRITICAL: human review required.

Follow-up pass — 1 commit since 562db18 (1058a24). All 3 prior issues are now closed. No new findings.


Prior-issue closure

1. Blocking — Duplicate release path (service vs. admin) → Fixed (562db18, unchanged)
Shared helpers in intent_claims.go mechanically enforce the invariant. Closed.

2. Minor — Misleading rebuild log count → Fixed (562db18, unchanged)
Counter increments only when added > 0. Closed.

3. Non-blocking — 2-minute rebuild timeout operator documentation → Fixed (1058a24)
NewOffChainTxStore now emits a timeout-specific error:
"failed to rebuild offchain tx inputs from stored txs within %s: %v" (with rebuildTimeout interpolated) when ctx.Err() != nil after rebuildInputs returns, and falls through to the generic message when the context is live. A docstring on NewOffChainTxStore also explains why the constructor fails fatally rather than degrading (partial rebuild = ClaimFresh answers for every in-flight tx it didn't reach). This is the right place to put it. Closed.


Incremental review of 1058a24

This commit is documentation and error-message only. No logic was changed.

internal/core/ports/live_store.go:87–95
The new comment on Add correctly states the arkTxid invariant: because an arkTxid is the txid of the ark transaction committing to the checkpoint outputs it spends, a different input set implies a different arkTxid, so the same-owner idempotence path can only be reached with identical inputs. The documented failure mode ("inputs only the previous body named would stay claimed") is accurate and useful — it describes exactly what Remove would miss if the invariant were ever broken. No issue.

internal/infrastructure/live-store/redis/offchain_txs.go:149–159
The ctx.Err() != nil check is correct for distinguishing deadline expiry from other errors. One edge case is benign: if rebuildInputs returns a non-context error that coincides with the deadline having just passed, the operator sees the "within timeout" message rather than the underlying error — but %v in the message still includes the wrapped error text, so it is not silenced. Acceptable.


No protocol, security, or correctness findings in this increment. The single-spend enforcement path reviewed in prior passes is unchanged.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — live-store: updated (claim release on DeleteIntents)

Since my last review (sha ad348b9), the key addition is intent_claims.go and the rework of DeleteIntents in the admin service.

The bug fixed: the previous DeleteIntents implementation wiped the store directly or called Delete without releasing claims first. If an intent holding vtxo claims was deleted without releasing them, those vtxos would remain claimed indefinitely — locked out of future rounds with no recovery path.

Fix is correct:

  • Snapshot intents first, then release their claims, then delete exactly that snapshot by id. This avoids the TOCTOU risk of releasing an intent that was registered after the snapshot (whose claim was never released and which round-start reconcile can't see since it was never popped).
  • releaseClaimsOfIntents logs failures rather than returning them, because the delete path proceeds regardless, and a lost release is a stale claim rather than a caller-visible error.
  • The test "the service and the admin service release identically" is particularly valuable: it pins the contract that both code paths release through the same helper, preventing drift.

Redis init: the NewLiveStore error is now surfaced — a silent init failure would produce a broken live store.

Test coverage is comprehensive. Looks ready to merge.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested on 2026-07-27 (44 days ago) with no update. @bitcoin-coder-bob need any help addressing the feedback?

Nineteen comments used a colon where a conjunction or a second sentence reads
better, including the five ARGV layout notes on the Lua scripts.

The ClaimOutpoints doc carried an issue number, and one comment in
SubmitOffchainTx described the mutex-guarded check the claim replaced. Both are
facts about the change rather than about the code, so they belong in the commit
message and the PR description.

The redis key comment kept its substance, since a deliberately new key name that
avoids a WRONGTYPE during a mixed-version rollout is the kind of decision that
looks arbitrary without it.

Comments only. No statement changed.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana follow-up review — b0ca2a6

Status: One cosmetic commit since the last pass; 3 of 3 prior code findings are fixed, 1 ops item remains open. No new code findings.


What changed

Commit b0ca2a6b0b ("live-store: drop colon syntax and an issue reference from the comments") rewrites comment text across seven files — grammar, punctuation, and prose style. No logic is touched in any of those 20 changed lines.


Prior findings — close-out

Fixed — wire.OutPoint.String() vs domain.Outpoint.String() format mismatch
internal/infrastructure/live-store/redis/offchain_txs.go:420–425
checkpointInputs now constructs a domain.Outpoint{Txid: ..., VOut: ...} and calls .String() on it, with an explicit comment tying it to ClaimOutpoints, ReleaseOutpoints, and Includes. Fixed at the baseline commit (1058a24003) by the "fail closed on a failed rebuild, parse checkpoint inputs all or nothing" commit, predating this increment.

Fixed — offchainTxMu removal, no nil-deref survivors
Answered by @bitcoin-coder-bob: the struct field, every initialisation (including script_watch_test.go), and every call site are gone. The only remaining mention is a comment in service.go describing what the claim replaced. Confirmed clean.

Fixed / No longer applicable — conflict.String() shape on boarding inputs
Answered by @bitcoin-coder-bob: claims are only taken over VTXO inputs. A boarding-only intent takes no claims and is covered by its own test. The ClaimConflict branch is therefore unreachable for non-VTXO input types; no zero-value or empty conflict outpoint can surface there.

Still open (ops) — Redis key-type migration window
The key rename from offChainTxStore:inputs (SET) to offChainTxStore:inputsByOwner (HASH) avoids a WRONGTYPE error on a mixed-version rolling deploy, but there is a brief window during the upgrade where in-flight off-chain txs that accepted under the old version retain their stored body without re-registration in the new hash until the first restart of an upgraded instance. The PR description explains the rebuildInputs startup path that closes this, and the commit logs describe the fallback. This needs ops sign-off on the upgrade sequencing (quiesce or accept the window) — it is not a code defect, and nothing in this increment changes the situation.


No new findings from the increment. Protocol-critical flag from the prior pass stands — human sign-off before merge is still the right call.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — live-store: owner-tagged intent claim release

Protocol-critical (vtxo claim management). Flagging for human sign-off.

This PR was reviewed at sha 49ec1a83 on 2026-09-04 (questions about AggregatedNonces clone race and ReleaseOutpoints race). Re-reviewing the updated diff.

What changed since last review:

  • DeleteIntents (admin) now snapshots intents, releases their claims, then deletes exactly the snapshot. This fixes the race where a delete-all could wipe an intent that registered after the snapshot, leaving its vtxo claims stranded.
  • releaseClaimsOfIntents is now a shared helper used by both the service and adminService paths, preventing them from drifting.
  • intent_claims.go + comprehensive tests in intent_claims_test.go covering: per-owner release, lookup-by-id, boarding-only intents (nothing to release), lookup failures, round-start selected-intent release, delete-by-proof, and the delete-all snapshot race.
  • Redis live store constructor now returns an error (NewLiveStoreerr checked in config).

Looks solid.

  • The snapshot-then-delete pattern in admin DeleteIntents correctly handles the race. The test "admin delete-all keeps an intent registered after the snapshot" directly exercises it with an afterViewAll hook — well done.
  • releaseClaimsOfIntents is idempotent: failed releases are logged rather than surfaced, which is correct since the caller drops the intent regardless.
  • The "the service and admin service release identically" test pins the two paths against each other, which is exactly the right regression guard.

Previously raised questions:

  1. AggregatedNonces clone race — is that concern addressed in this diff or a follow-up?
  2. ReleaseOutpoints concurrency — the owner-tagging design means concurrent releases for different owners are independent; a concurrent release for the same owner on the same outpoint is idempotent. Looks addressed by the design.

Looks ready pending confirmation that the AggregatedNonces concern from the previous review has been resolved.

The colon check keyed on a lower-case letter following the colon, so a sentence
continuing with a capital slipped through.
Both sat at the end of a comment line, where the first pass only looked for a
colon mid-sentence. Repairing them also needed the clause on the following line,
which was left starting lower case.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana follow-up — #1162 (d860341, incremental from b0ca2a6)

Status: 2 commits since last pass, both comment-only (no logic changes). 3 prior issues fixed, 1 still open, 1 silently dropped in earlier passes now acknowledged.


Prior issue tracking

1. offchainTxMu nil-dereference concern — Fixed
Confirmed by @bitcoin-coder-bob: the field and every initialisation site are gone. No code path can dereference it. Closed.

2. conflict.String() for non-vtxo input types — Fixed
Confirmed by @bitcoin-coder-bob: boarding-only intents take no claims and cannot reach the ClaimConflict branch. The conflict value is always a typed *domain.Outpoint. Closed.

3. Comment colon style — Fixed (commits e94baf3, d860341)
These two commits are the entire incremental diff. They fix the remaining mid-sentence and end-of-line colons in redis/offchain_txs.go and live_store_test.go that earlier passes missed. All three comment-only commits collectively address the prose style feedback. No logic changed.

4. Rolling-upgrade / Redis key-type quiescing — Still open
The old offChainInputsSetKey (Redis SET) is not migrated to the new offChainInputsHashKey (Redis HASH). A rolling deploy leaves a window where an in-flight tx retains its stored body but loses input-claim coverage when the old key expires. @bitcoin-coder-bob explicitly flagged this as needing ops sign-off rather than code review. That sign-off has not appeared in this thread. This must be resolved — via a documented upgrade runbook, a one-time migration step, or explicit acceptance — before the PR lands.

5. SigningContext.AggregatedNonces slice not deep-copied — Acknowledged as silently dropped
I raised this in the first pass ("the AggregatedNonces slice inside the maps.Clone snapshot isn't cloned; concurrent mutation is still a data race if that path exists") and then dropped it from subsequent reviews without closure. The incremental diff does not touch that code, so I cannot close it here. Before merge, confirm whether any call site mutates the nonce slice after Get returns, and if so add a slices.Clone or equivalent. If no call site mutates it, note that explicitly so future reviewers do not re-raise it.


Incremental diff assessment

No logic was changed in these two commits. The substantive review remains as filed. The single blocking item is rolling-upgrade ops sign-off (item 4 above).

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 45+ days ago. @bitcoin-coder-bob need any help addressing the feedback?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — #1162 (sha d860341)

live-store: owner-tagged atomic single-spend domain (updated)

This is an update since my previous review. The new intent_claims.go / intent_claims_test.go files are the main addition.

What changed / review

releaseClaimsOfIntents helper (intent_claims.go):

  • Centralises the claim-release logic that previously had to be duplicated between service and admin paths.
  • Release is per-owner and idempotent — a claim already gone or taken by someone else is left alone. Correct.
  • Failure is logged-not-returned on purpose: the caller always drops the intent regardless, so a stale claim (bad but survivable) is better than surfacing an error that hides the real operation.

DeleteIntents fix (admin.go):

  • Old code: DeleteAll when no IDs passed, which wiped the store atomically without releasing claims.
  • New code: snapshot → release claims → delete by ID. Correctly avoids deleting an intent registered after the snapshot (whose claim was never released).
  • Test admin delete-all keeps an intent registered after the snapshot exercises this race explicitly — good.

config.go fix:

  • NewLiveStore now returns (LiveStore, error), and the error is propagated. This was a latent bug (unchecked init failure for Redis live store).

Tests (intent_claims_test.go):
Very comprehensive: covers boarding-only intents (no release), lookup failure (no panic, no wrong release), round-start selected-intent release (only non-queued ones), delete-by-proof, admin delete-all with concurrent registration, and the symmetry test that proves the service and admin service use the same release path. Coverage is excellent.

Minor note

The releaseClaimsOfSelectedIntents and releaseClaimsOfIntentIds methods on service — these appear in tests but weren't in the diff snippet. Assuming they call through to releaseClaimsOfIntents as the tests imply.

Looks ready to merge. The original review concern (unchecked DeleteAll leaking claims) is addressed.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — #1162 live-store: owner-tagged atomic single-spend domain

⚠️ PROTOCOL-CRITICAL (double-spend prevention) — flag for human review before merge.

The DeleteIntents fix and releaseClaimsOfIntents consolidation look correct.

DeleteIntents fix (admin.go). The old code called DeleteAll (wipe the store) or Delete(ids) without releasing claims first. The new path snapshots with ViewAll, releases claims for each intent under its own id, then deletes exactly the snapshotted set. The comment explaining the race is accurate: a wipe would also drop an intent registered after the snapshot whose claims were never released, leaving vtxos claimed forever. This is the right fix.

releaseClaimsOfIntents (intent_claims.go). Extracting the release logic to a package-level helper shared by all delete paths is the right call. The comment on "two copies of this that drifted apart" explains the motivation clearly. Error on release is logged rather than returned — intentional, the comment gives the right reasoning.

Tests (intent_claims_test.go). The table covers: per-owner release, id-only lookup path, boarding-only intent (no claims), lookup failure, round-start selected-intent release, and delete-by-proof. The claimIntentStore.selected vs all test for round start is the tricky one and it's correct: only intents in selected but not in all (i.e., those not re-queued) should release.

One small observation: the recordingOffchainTxStore mock accumulates releases per-owner in a map. A test that calls release twice with the same owner would not detect duplicate releases. Not a blocking concern, but worth keeping in mind if new callers are added.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review · sha d860341f

Summary: Fixes a claim-leak bug in DeleteIntents and centralises the intent-claim-release logic into a single helper that both the service and admin service share.

Correctness of the fix:
The old DeleteAll() path wiped the store atomically, which could delete an intent registered after the snapshot started, leaving its vtxo claims held with no round cycle to release them. The fix snapshots first (ViewAll), releases claims against that snapshot, then deletes only the snapshotted IDs. This is the correct ordering.

The afterViewAll injection in the test ("admin delete-all keeps an intent registered after the snapshot") cleanly demonstrates the race that was possible and verifies that post-snapshot intents survive.

Observations:

  • releaseIntentClaims logs rather than returns an error on release failure. The comment explains why: the caller drops the intent regardless, and a failed release leaves a stale claim rather than a caller-visible error. This is a deliberate trade-off and the rationale is clear.
  • The releaseClaimsOfSelectedIntents logic for round-start is a nice fix: it only releases intents that were selected but are no longer queued, not all selected intents, preventing double-release of a re-pushed intent.
  • releaseClaimsOfIntents / intentsOf are well-decomposed. The cross-test ("the service and the admin service release identically") is an excellent guard against drift between the two paths.
  • Removal of offchainTxMu from service: this field disappears from the struct, initialisation, and tests. Confirm that no other branch still touches it.

Verdict: Looks ready. This is an important correctness fix with excellent test coverage. The shared claim-release helpers are the right architecture.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 7+ weeks ago on this PR. @bitcoin-coder-bob need any help addressing the feedback?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha d860341 ⚠️ PROTOCOL-CRITICAL (claim management) — flag for human review before merge.

What this does: three related correctness fixes in one PR:

  1. DeleteIntents claim leak fix (admin): Previously with no ids would call , dropping intents without releasing their vtxo claims. Now it snapshots via , releases claims from the snapshot, then deletes by the snapshot's ids. A registration racing between the snapshot and the delete is not deleted (correct — its claim is still active). The test verifies this precisely.

  2. ** RWMutex:** A new R/W mutex brackets registrations (R-locked for duration of each registration) and is write-locked while a finished batch's claims are released. This closes the window where a registration could read an input as unspent after the batch settled but before its projection landed. The naming is clear and the locking pattern is correct.

  3. ** → :** Generalises from waiting on a single batch id to waiting on all currently-tracked batches. A batch that doesn't land its projection within the timeout stays tracked (correct — the next call covers it). Previously a single missed projection could permanently drop tracking.

  4. Remove : The field is removed from struct and test fixtures. The protection it provided is now handled by the RWMutex above.

Tests are comprehensive — the suite covers every path including the race-condition scenario and the 'service and admin service release identically' invariant test, which is exactly the right thing to pin.

One nit: in , logs rather than returning the error 'since the caller is always on a path that drops the intent regardless'. The comment is a multi-line block with an awkward line break ('since the caller\n// is always…'). Minor style issue only.

Overall: this is a careful correctness fix with excellent tests. Looks ready. Human sign-off required before merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — sha d860341 — PROTOCOL CRITICAL

⚠️ Flagging for mandatory human review — this changes how intent claims are released on admin delete.

DeleteIntents fix:
The old path called DeleteAll / Delete directly, which wiped the store without releasing conflict-domain claims. The new path: (1) snapshots intents via ViewAll, (2) releases their claims, (3) deletes exactly that snapshot. This closes the claim-leak on admin delete.

The comments in the code correctly identify the two failure modes of the old approach:

  • An intent registered after the snapshot would have its claims freed but never be deleted.
  • The selected-intent set read by reconcile would be dropped, leaking claims of round-in-progress intents.

New intent_claims.go:

  • releaseIntentClaims is idempotent and owner-tagged — a release under the wrong id is a no-op, not a corruption.
  • releaseClaimsOfIntents is package-level so every claim-dropping path shares one implementation — drift between paths was the root cause of prior leaks.
  • Logging instead of returning errors on release failure is correct: the intent is being dropped anyway, a lost release is recoverable, and returning an error would block the delete.

Test coverage:
Tests exercise: correct owner tagging, id-based lookup, boarding intents (no vtxo inputs → no release), store-down path, and the round-start reconcile case. Good.

Looks ready to merge. ✅ (human sign-off for protocol-critical change)

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review — arkade-os/arkd #1162 — live-store: owner-tagged atomic single-spend domain (sha d860341)

PROTOCOL-ADJACENT — intent claim management, liveness of vtxo spend paths — human review recommended.

Summary: The core change is a correctness fix in DeleteIntents and the centralisation of claim release. Looks ready to merge.

The bug being fixed:
The old DeleteAll path wiped the intent store, which could delete an intent registered after the snapshot and whose vtxo claims were never released. Those claims would then be permanently held, making the vtxos unspendable. The new path: snapshot → release claims by id → delete exactly the snapshot. A registration racing between snapshot and delete is left intact.

New intent_claims.go:

  • releaseClaimsOfIntents is shared by both service and adminService, eliminating the risk of the two paths drifting.
  • releaseIntentClaims logs rather than returns errors (correct — the caller is always dropping the intent anyway; a lost release is a stale claim, not a fatal error, and reporting it as fatal would be worse).

Tests are comprehensive: covers the main paths (by-id, by-proof, delete-all, delete-all keeps post-snapshot registration, error handling, cross-service consistency). The "service and admin service release identically" test is particularly valuable for catching future divergence.

Stale redis live store error handling: the diff shows liveStoreSvc, err = redislivestore.NewLiveStore(...) where the original ignored the error. Good fix.

No concerns. Looks ready.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 50+ days ago. @bitcoin-coder-bob need any help addressing the feedback on the single-spend domain?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested on this PR (live-store atomic single-spend domain) over 7 weeks ago with no update since the last push. @bitcoin-coder-bob — need any help addressing the feedback?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Live-store correctness fix for intent deletion.

What changed and why it matters

The old DeleteAll path wiped the store, which would:

  1. Drop intents registered after the snapshot was taken, leaving their vtxos claimed with no round to release them.
  2. Remove the selected-intent set that round-start reconcile reads, leaking claims from the running round.

The fix snapshots intents first, releases their claims (owner-tagged and idempotent), then deletes the exact snapshot by id. Any intent registered between snapshot and delete survives untouched.

Tests are excellent

TestIntentClaimRelease covers the full surface area: delete-by-proof, delete-all safety (intent registered after snapshot is kept), the service-vs-admin-service equivalence test, and the round-start selected-intent release. The equivalence test in particular is a good guard against the two paths drifting.

One comment on error handling

releaseIntentClaims logs rather than returning the error, with the rationale that a failed release leaves a stale claim (bad but recoverable) while turning it into a hard error would block intent deletion entirely (worse). That's a reasonable trade, but the comment should note what "stale claim" means for the operator — specifically that the vtxo stays locked until the claim TTL expires or the server restarts, whichever applies to the live-store backend.

Minor nit: intentsOf strips timing metadata to get to the domain type. It would be clearer as a method on TimedIntent rather than a package-level function, but that is a style preference.

Looks ready to merge.

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — 2026-09-18

Summary

Fixes a claim-leak bug in DeleteIntents: the old code called Intents().DeleteAll() or Delete() without releasing the vtxo claims those intents held, leaving their inputs permanently claimed. The fix snapshots the intents first, calls releaseClaimsOfIntents, then deletes exactly that snapshot.

What looks good

  • The fix correctly handles the race: intents registered after the snapshot are not in the delete list, so their claims are never released — they remain safe.
  • The new intent_claims.go helper is package-level, shared by both the service and the admin service, with a test that verifies both paths produce identical release behaviour. This is exactly the right way to prevent drift.
  • Extensive test coverage: boarding-only intents (no claims to release), lookup failure, round-start selected-intent release, delete-by-proof, and the critical service-vs-admin equivalence test.
  • The ViewAll-then-Delete pattern is correctly atomic in spirit: if Delete fails, claims are already released (idempotent), which is the safe direction.

Minor

  • The comment in admin.go explaining why DeleteAll was replaced (would silently drop running-round state) is excellent.

Looks ready to merge. The test that verifies service and admin service release identically is particularly valuable.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested some time ago. @bitcoin-coder-bob need any help addressing the feedback?

@arkana-ai-bot

Copy link
Copy Markdown

My review on 2026-09-18 noted this looks ready to merge. @bitcoin-coder-bob — is there anything blocking a final approval here?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 2+ days ago with no code update. @bitcoin-coder-bob need any help addressing the feedback?

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested 7+ weeks ago (opened 2026-07-27). @bitcoin-coder-bob need any help addressing the feedback?

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Arkana review — #1162 (sha d860341) — catchup pass

Prior reviews at ad348b9 and bf4c653 flagged the protocol-critical nature of this PR. This pass covers changes since bf4c653.


New in this diff

  1. NewLiveStore returns errorNewOffChainTxStore now returns an error and NewLiveStore propagates it. The caller in config.go handles it correctly. This closes the gap where a store-init failure was silently swallowed.

  2. DeleteIntents snapshot-then-release-then-delete — Replaces the old wipe-all path. The key invariant: the intent list is snapshotted first, claims are released under each intent's own owner-id, then only the snapshotted ids are deleted. A new intent registered after the snapshot isn't deleted and its claims aren't freed spuriously. ✅

  3. intent_claims.goreleaseClaimsOfIntents and releaseIntentClaims are correctly owner-tagged and idempotent. Moving this to a package-level helper ensures all intent-drop paths use the same release logic. ✅

  4. e2e test — Destinations are now randomly generated, avoiding the same-txid-idempotent problem in the double-spend test. Good fix.


Previously raised issues — status

  • Redis Lua atomic check-and-claim: addressed in prior commits.
  • offchainTxMu removal: done.
  • The prior CHANGES_REQUESTED concerned issues in those earlier commits. Nothing in the current diff reintroduces them.

Remaining concern

Confirm that releaseClaimsOfIntents is also called from the round-start reconcile path when intents are evicted, not only from DeleteIntents. If a round ends and intents are pruned without going through DeleteIntents, claims could leak.


⚠️ PROTOCOL-CRITICAL — still requires human sign-off before merge. The atomic single-spend domain is the core double-spend barrier. Current code looks correct; the main remaining question is the reconcile path above.

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.

2 participants