Skip to content

domain,db: add VtxoKind discriminator for on-chain Arkade UTXOs (part of #1159) - #1161

Open
bitcoin-coder-bob wants to merge 10 commits into
masterfrom
bob/onchain-arkade-vtxo-kind
Open

bitcoin-coder-bob wants to merge 10 commits into
masterfrom
bob/onchain-arkade-vtxo-kind

Conversation

@bitcoin-coder-bob

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

Copy link
Copy Markdown
Collaborator

Second slice of #1159 (on-chain Arkade execution). Not stacked on #1160: it shares no files with #1160 or #1162, is based on master, and merges cleanly on its own. See "Merge ordering" below for why it should still go in first.

Adds the VtxoKind discriminator. The design doc's original "no schema change" assumption was false. A purely on-chain-derived Arkade UTXO misclassifies against the existing vtxo fields (empty commitment fields read as a note via IsNote(); Unrolled=true routes into the sweeper which treats SpentBy as a checkpoint txid). A discriminator resolves this.

Confirmed on the #1159 design call: the direction is a unified indexer with a type flag rather than a separate on-chain indexer, which is what this builds. An on-chain unconfirmed state is wanted eventually, which the enum absorbs without a second migration (see "Why an enum, not a bool" below).

What this does

  • domain.Vtxo gains a Kind field (VtxoKindOffchain=0, VtxoKindOnchain=1). IsNote() keys off it so an on-chain vtxo with empty commitments is not a note, and IsExpired() returns false for it since there is no batch expiry on-chain.
  • RequiresForfeit() returns false for the on-chain kind. Excluding it from IsNote() had flipped this to true, and an on-chain UTXO joins a batch as a boarding input that is signed directly and never forfeited. Guarded explicitly alongside IsNote() and IsExpired().
  • sqlite + postgres migrations add vtxo_kind INTEGER NOT NULL DEFAULT 0 and recreate vtxo_vw / intent_with_inputs_vw so it is visible (vtxo_vw is SELECT v.*; the intent view enumerates columns, so v.vtxo_kind is added by hand). DEFAULT 0 backfills all history as offchain, no data migration.
  • badger persists it via gob with no migration; old records decode to the zero value (offchain).
  • UpsertVtxo (write) and rowToVtxo (read) carry Kind on both SQL backends.

Why an enum, not a bool

Same migration cost, but future on-chain sub-kinds (assets, cosigned-vs-unilateral-exit) need no further migration. This was a judged decision (explicit enum vs bool vs derive-from-fields); derive-from-fields was rejected because it overloads Preconfirmed for every reader and opens a new fraud misfire.

Scope / behaviour

Behaviour-preserving: nothing writes Onchain yet, so every existing row is offchain and all classifiers evaluate as before. The protective read-guards (sweeper/indexer filters that keep on-chain rows away from the SpentBy-as-checkpoint readers) deliberately land with the on-chain write path, where they are testable against real on-chain rows.

Second commit: carry Kind through the round and marker converters

rowToVtxo carried Kind from the start, but the round-replay converter (combinedRowToVtxo) and the marker converters built domain.Vtxo values without it, so a vtxo read through those paths came back as VtxoKindOffchain no matter what was stored. Any guard keyed on Kind downstream of round replay or marker preload would have been silently wrong, which is the kind of gap that only shows up once something depends on it. Now wired on both SQL backends.

Adjacent finding, left alone: combinedRowToVtxo also drops Depth and MarkerIDs. That predates this PR (it came in with the DAG work), so round replay does not reconstruct the DAG fields either. Flagged rather than fixed here to keep this change scoped.

Test plan

  • go build ./..., make lint (0 issues), gofmt clean
  • TestVtxo_IsNote extended: an onchain-kind vtxo with empty commitments is not a note
  • TestVtxo_RequiresForfeit gains the on-chain-kind case, verified to fail without the guard
  • TestAddVtxoKindDownMigration (new) runs on both sqlite and postgres: up adds the column and surfaces it in vtxo_vw, down drops it and recreates the views, re-apply works. The postgres case runs against a database the test owns on the shared test server, so it never touches TestService state
  • sqlite package suite green
  • postgres full integration (TestService/.../postgres_stores) run on a fresh DB, migration chain applies and the vtxo round-trip carries Kind
  • badger + core + domain suites green

Merge ordering

Merge this one first of the open #1159 PRs (#1160, #1161, #1162, and #1174, which also waits on this: its onchain-spend tracking is scoped to unrolled = true today, and the follow-up that widens it to Kind = Onchain builds on the discriminator added here). Not for a code dependency, there is none and the three merge cleanly in any order, but because of the migration. It is stamped 20260901000000, and the repo uses golang-migrate, which tracks a single current version and silently skips any migration numbered below it. If a later-stamped migration lands on master and reaches a deployed database before this merges, the vtxo_kind column never gets added there. Master's latest migration is 20260807120000 today. If that changes before this merges, bump this migration's timestamp past it.

Review notes

Status

Out of draft. The discriminator representation (unified indexer with a type flag, enum rather than bool) was confirmed on the #1159 design call.

Summary by CodeRabbit

  • New Features

    • VTXOs now distinguish between off-chain and on-chain holdings.
    • On-chain VTXOs are excluded from note classification, do not require forfeits, and do not expire.
    • VTXO type is preserved when storing and retrieving data.
  • Database

    • Existing VTXOs default to off-chain during migration.
    • Database migrations can be safely applied and reversed.
  • Tests

    • Added coverage for on-chain behavior, persistence, and migration reversibility.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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: 0058b2b8-aca5-4fa6-a95c-46c8a313009d

📥 Commits

Reviewing files that changed from the base of the PR and between 3bf9080 and 54adb21.

📒 Files selected for processing (4)
  • internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.down.sql
  • internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.up.sql
  • internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.down.sql
  • internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.up.sql
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.down.sql
  • internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.up.sql
  • internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.up.sql
  • internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.down.sql

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


Walkthrough

The VTXO domain now distinguishes offchain and onchain kinds. PostgreSQL and SQLite persist and restore this discriminator. Onchain VTXOs are excluded from note classification and forfeiture checks, and never expire. Migration rollback tests cover both databases.

Changes

VTXO kind support

Layer / File(s) Summary
Domain kind semantics
internal/core/domain/vtxo.go, internal/core/domain/vtxo_test.go
Adds VtxoKind, stores it on Vtxo, applies onchain-specific note, forfeiture, and expiry rules, and tests these cases.
PostgreSQL kind persistence
internal/infrastructure/db/postgres/...
Adds vtxo_kind to the PostgreSQL schema and views. Generated queries persist and select it. Repository converters map it to domain.Vtxo.Kind.
SQLite kind persistence
internal/infrastructure/db/sqlite/...
Adds vtxo_kind to the SQLite schema and views. Generated queries and repositories persist and restore it.
Cross-backend migration validation
internal/infrastructure/db/vtxo_kind_down_test.go
Applies, rolls back, and reapplies the migration on SQLite and PostgreSQL while checking the table column and views.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Merge Risk: ⚪ Minimal · up to 54adb

No merge-blocking issue is identified in the current change.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 18 functions across 13 files. (4 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding the VtxoKind discriminator for on-chain Arkade UTXOs across the domain and database layers.
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 18 functions across 13 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/onchain-arkade-vtxo-kind

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.

Adds a vtxo_kind column (0 = offchain, 1 = onchain) so an on-chain Arkade UTXO
(issue #1159) can be told apart from a batch leaf or offchain-tx output. Without
it, such a vtxo misclassifies: empty commitment fields read as a note, and the
sweeper would treat SpentBy as a checkpoint txid.

- domain.Vtxo gains a Kind field; IsNote() keys off it so an on-chain vtxo with
  empty commitments is not a note.
- sqlite + postgres migrations add the column (DEFAULT 0 backfills all history
  as offchain) and recreate vtxo_vw / intent_with_inputs_vw so it is visible.
  badger persists it via gob with no migration; old records decode to offchain.
- UpsertVtxo and rowToVtxo carry Kind on both SQL backends.

Chosen as an explicit enum over a bool so future on-chain sub-kinds need no
further migration. Behaviour-preserving: nothing writes Onchain yet, so every
existing row is offchain and all classifiers evaluate as before. The protective
read-guards (sweeper/indexer filters) land with the on-chain write path, where
they are testable against real on-chain rows.

Part of #1159.
rowToVtxo on both SQL backends already carried Kind, but the round-replay
converter (combinedRowToVtxo) and the marker converters built domain.Vtxo
values without it, so a vtxo read through those paths came back as
VtxoKindOffchain regardless of its stored kind. Any guard keyed on Kind
downstream of round replay or marker preload would have been silently wrong.

Note combinedRowToVtxo also drops Depth and MarkerIDs, which predates this
work. Left alone here rather than widening the change, but it means round
replay does not reconstruct the DAG fields either.
An on-chain Arkade UTXO has no batch expiry, so ExpiresAt is not meaningful for
it and is left zero. Without this guard IsExpired would compare against the Unix
epoch and report every on-chain vtxo as expired, which cascades through
RequiresForfeit and the two spend-path checks in service.go and would make them
permanently unspendable.

Behaviour-preserving today: nothing writes VtxoKindOnchain yet, so every
existing row is offchain and evaluates exactly as before.
@bitcoin-coder-bob
bitcoin-coder-bob force-pushed the bob/onchain-arkade-vtxo-kind branch from be8c7fc to fc48791 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

Copy link
Copy Markdown

This PR has been open for 14 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.

Arkana review — sha fc48791

Looks ready to merge.

The VtxoKind discriminator is a clean additive change. The two domain fixes it enables are necessary and correct:

  • IsNote(): An on-chain UTXO has no commitment txids, so without the kind check it would silently masquerade as a note. The early Kind != VtxoKindOnchain guard is the right place.
  • IsExpired(): A zero ExpiresAt on an on-chain UTXO would read as the Unix epoch (1970), meaning every on-chain UTXO would appear permanently expired. The short-circuit is correct.

Migration safety: ALTER TABLE vtxo ADD COLUMN IF NOT EXISTS vtxo_kind INTEGER NOT NULL DEFAULT 0DEFAULT 0 means all existing rows map to VtxoKindOffchain with no backfill needed. The view recreation is straightforward.

The down.sql rolls back cleanly.

Tests cover both new cases. No concerns.

@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 — domain,db: add VtxoKind discriminator (part of #1159)

⚠️ PROTOCOL-CRITICAL — changes vtxo state interpretation. Flagging for human sign-off even though the logic looks correct.

What changed:
Adds (uint8 enum) to with (0, default) and . Fixes two methods:

  1. **** — an on-chain Arkade UTXO has no commitment txids (like a note), so without the kind check it would read as a note. The guard prevents that.

  2. **** — an on-chain Arkade UTXO has (zero value), which would always return true (1970 is in the past). The guard returns for , preventing all such vtxos from reading as permanently expired and being treated as unspendable.

** as open enum (uint8):** Intentional design note says future on-chain sub-kinds can be added without a schema migration — this means callers that switch on the kind need to handle unknown values. Worth ensuring any switch in downstream code has a default case.

Tests: Both and have the onchain fixture case. Good.

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.

Review — VtxoKind discriminator

Verdict: looks ready to merge. Stacked on #1160.

Additive domain change to distinguish off-chain batch/offchain-tx VTXOs from on-chain Arkade UTXOs (#1159 feature).

Key correctness fixes this PR addresses:

  1. IsNote(): on-chain Arkade UTXOs have no commitment txids, so without the kind check they'd read as notes. The guard is correct.
  2. IsExpired(): on-chain Arkade UTXOs carry a zero ExpiresAt (no batch expiry). Without the guard every such VTXO would be permanently "expired" and rejected by any check that calls IsExpired(). The guard is correct.

DB migration:

  • Additive column: ALTER TABLE vtxo ADD COLUMN IF NOT EXISTS vtxo_kind INTEGER NOT NULL DEFAULT 0. Existing rows get VtxoKindOffchain (0) automatically — no backfill needed. ✅
  • Views dropped and recreated to expose the column. Down migration reverses this correctly. ✅
  • sqlc-generated code updated consistently across all SELECT queries that project from vtxo_vw and intent_with_inputs_vw. ✅

One observation: VtxoKind is typed as uint8 in Go but INTEGER (int32) in Postgres (via sqlc's sql.NullInt32/int32). The conversion domain.VtxoKind(row.VtxoKind) truncates int32 → uint8, which is fine as long as the column stays small (it will). A comment noting this would help future readers, but it's not blocking.

Tests are present and cover the two new cases (onchain kind not-a-note, onchain kind never-expired). ✅

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 5+ days without a review. @bitcoin-coder-bob is anyone looking at this? (VtxoKind discriminator (part of #1159); 5 days without review.)

@bitcoin-coder-bob
bitcoin-coder-bob marked this pull request as ready for review September 1, 2026 12:55

@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.

Summary

This PR adds a VtxoKind discriminator (VtxoKindOffchain=0, VtxoKindOnchain=1) to domain.Vtxo and wires it through SQLite, Postgres, and Badger persistence. The approach is sound for what it sets out to do, and the IsNote() / IsExpired() fixes are correct. However, there are several issues that need attention before merge.


🔴 Must-fix: RequiresForfeit() will misfire on VtxoKindOnchain vtxos

internal/core/domain/vtxo.go:81

func (v Vtxo) RequiresForfeit() bool {
    return !v.Swept && !v.IsNote() && !v.Unrolled
}

IsNote() now correctly returns false for VtxoKindOnchain. As a side-effect, an unspent, non-swept, non-unrolled on-chain vtxo will have RequiresForfeit() == true once #1159 begins writing them. This propagates to at least five call sites — some of which are not "sweeper/indexer" paths but round-processing and forfeit-initialization paths:

  • internal/core/application/service.go:1852 — attempts taproot-script validation on the vtxo, which will either fail or pass on wrong script expectations.
  • internal/infrastructure/live-store/inmemory/forfeits.go:37 / redis/forfeits.go:51 — adds the vtxo to the forfeit-signing list.
  • internal/infrastructure/db/service.go:856 — expects a matching forfeit txid in forfeitInputs; on-chain vtxos will silently map to an empty spentBy.
  • internal/core/domain/intent.go:117 / round.go:174 — gates round state transitions on RequiresForfeit().

The PR body says protective guards land with the write path (#1159). That is acceptable for the sweeper / SpentBy-as-checkpoint readers. But RequiresForfeit() is a domain-level invariant, not a sweeper concern; there is no call-site-level guard that prevents it from misfiring once on-chain vtxo records exist. A future PR writer or merge-conflict resolution can easily fail to add the guard before the write path is exercised.

Required action: Either extend RequiresForfeit() to return false for VtxoKindOnchain (the safest change here, symmetric with the IsNote() and IsExpired() fixes), or add an explicit TODO/compile-time note that every RequiresForfeit() call site must be guarded before #1159 lands. A test (TestVtxo_RequiresForfeit) covering the onchain-kind case would catch a regression.


🟡 Postgres down-migration has no automated roundtrip test

internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.down.sql

TestAddVtxoKindDownMigration (in vtxo_kind_down_test.go) runs against an in-memory SQLite instance via newSweptVtxoMigrator. The Postgres down migration is untested at the migration-layer level; the integration run described in the PR ("fresh DB") exercises the up path only. If the Postgres down migration has a syntax or dependency-ordering error it will surface only in a live rollback.

This is lower risk than the RequiresForfeit issue but should be noted. Consider a CI job or separate test that applies the full Postgres migration chain and back.


🟡 combinedRowToVtxo still drops Depth and MarkerIDs

internal/infrastructure/db/postgres/round_repo.go:708 / internal/infrastructure/db/sqlite/round_repo.go:849

The PR correctly flags this pre-existing gap. After this change, Kind is now wired through round replay but Depth and MarkerIDs are still dropped. Any downstream code that branches on Kind for vtxos read through round replay will have a vtxo with correct Kind but incorrect Depth and no MarkerIDs. That mismatch could produce silent errors if DAG-aware logic is added before combinedRowToVtxo is fixed. A follow-up issue / TODO comment in the code would help.


🟡 No range-check on VtxoKind at deserialization

internal/infrastructure/db/postgres/vtxo_repo.go:648, internal/infrastructure/db/sqlite/vtxo_repo.go:737, and all combinedRowToVtxo sites.

Kind: domain.VtxoKind(row.VtxoKind),

VtxoKind is declared as uint8; the stored column is INTEGER (32/64-bit). An out-of-range value stored by a future migration, a direct SQL edit, or a bug in #1159 will cast silently to an undefined VtxoKind value, bypassing all IsNote() / IsExpired() guards. Consider a small helper:

func vtxoKindFromInt(v int32) (VtxoKind, error) {
    switch VtxoKind(v) {
    case VtxoKindOffchain, VtxoKindOnchain:
        return VtxoKind(v), nil
    default:
        return VtxoKindOffchain, fmt.Errorf("unknown vtxo_kind %d", v)
    }
}

Or at minimum a metric / log on unrecognised values so the operator knows before a customer reports funds gone.


🟡 SQLite down migration: DROP COLUMN without IF EXISTS

internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.down.sql:3

ALTER TABLE vtxo DROP COLUMN vtxo_kind;

The Postgres down migration uses DROP COLUMN IF EXISTS. The SQLite variant does not. If the column is missing (interrupted partial rollback), the SQLite down migration will error out rather than succeed idempotently. On SQLite ≥ 3.35.0 IF EXISTS is supported for DROP COLUMN; prefer consistency with Postgres.


✅ Confirmed correct

  • DEFAULT 0 backfill: existing rows correctly become VtxoKindOffchain with no data migration.
  • vtxo_vw is SELECT v.* and picks up vtxo_kind automatically after recreation — no column enumeration risk.
  • intent_with_inputs_vw enumerates columns and has v.vtxo_kind added explicitly in the up migration — correct.
  • Badger vtxoDTO embeds domain.Vtxo via gob; old records decode with Kind=0 (offchain) — correct.
  • IsNote() and IsExpired() fixes are well-reasoned and tested.
  • View drop order (intent first, then vtxo_vw) is safe — intent_with_inputs_vw selects from the base vtxo table, not from vtxo_vw.
  • combinedRowToVtxo Kind wiring: Postgres uses sql.NullInt32.Int32, SQLite uses sql.NullInt64.Int64 — consistent with the respective model types.
  • No cross-repo API surface change detected: VtxoKind is internal to arkd; SDKs (ts, go, rust, dotnet) have no vtxo_kind references.

Noted / out of scope (acknowledged in PR body)

  • SelectSweepableUnrolledVtxos will surface on-chain vtxos if they ever have unrolled=true; sweeper guard deferred to write path — acceptable for now.
  • SpentBy-as-checkpoint reader routing for on-chain vtxos — same deferral, same reasoning.

An on-chain Arkade UTXO joins a batch as a boarding input, signed directly and
never forfeited. IsNote already excludes the kind, which flipped RequiresForfeit
to true for it, so guard it explicitly alongside IsNote and IsExpired.
Same up, down, re-apply sequence on both SQL backends, against a database the
test owns on the shared test server.

@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 — 9c37440 vs b1e0e7b. Two substantive commits. Zero prior issues to close (previous pass at fc48791 raised none).


Prior issues

None raised in the last pass; nothing to close.


New commits in scope

Three commits land since b1e0e7b:

sha subject
f863e48 Migrate to btcd v0.26 and the v2 sub-modules (#1173) — merged from master, reviewed separately
4b3a433 domain: on-chain kind never requires a forfeit
262e626 db: run the add_vtxo_kind down-migration test on postgres too

The btcd v0.26 rename (f863e48) is a mechanical import-path rewrite that arrived via the master merge; it does not change any logic on this branch. I will not re-audit it here.


4b3a433RequiresForfeit() guard (internal/core/domain/vtxo.go:96)

Correct. Before this commit, RequiresForfeit() was:

return !v.Swept && !v.IsNote() && !v.Unrolled

For a VtxoKindOnchain VTXO that carries commitment txids (so IsNote() correctly returns false because of the kind check already there), the old expression evaluates true — signalling a forfeit path that onchain boarding inputs never take. The new guard:

return v.Kind != VtxoKindOnchain && !v.Swept && !v.IsNote() && !v.Unrolled

short-circuits immediately for any onchain kind, regardless of sweep/note/unroll state.

The added test case (vtxo_test.go:191-202) exercises exactly the dangerous scenario: an onchain VTXO with a non-empty CommitmentTxids slice (which would have defeated the IsNote() escape) and a future ExpiresAt, asserting requiresForfeit: false. Coverage is complete.

No concerns.


262e626 — Postgres down-migration test (internal/infrastructure/db/vtxo_kind_down_test.go)

Clean. The refactor extracts a schema interface and adds a postgresSchema implementation, so the same up/down/re-up assertion sequence runs on both backends.

Points checked:

  • newVtxoKindPostgresMigrator calls newMigrator().Drop() to wipe prior state, then constructs a fresh instance — correct, because Drop invalidates the migrator it ran on.
  • information_schema.columns query is fully parameterised ($1, $2); no injection surface.
  • The SQLite PRAGMA table_info(%s) path remains unparameterised, but table names are hardcoded literals in the test — no risk.
  • The dedicated database (vtxo_kind_migration) is separate from TestService's database, so the two cannot clobber each other.
  • Hard-coded test-server credentials in a test constant is the existing project pattern; no new exposure.

One observation worth a follow-up (not a blocker): t.Run("postgres", ...) will hard-fail on any machine without a reachable test postgres server. The SQLite sub-test will still pass in isolation. If there are developer environments without postgres, a testing.Short() skip or a build tag would protect them. This matches the existing TestService behaviour, so it is not a regression here.


Summary

All new logic is correct. The RequiresForfeit fix closes a real gap: without it, any onchain VTXO that carried commitment txids would be treated as requiring a forfeit signature it can never provide. The migration-test extension gives both backends the same rollback coverage. No blocking issues.

@arkana-ai-bot

Copy link
Copy Markdown

Changes were requested on this PR (open 40+ days). @bitcoin-coder-bob need any help addressing the feedback? (VtxoKind discriminator)

@bitcoin-coder-bob
bitcoin-coder-bob dismissed arkana-ai-bot’s stale review September 3, 2026 17:37

Addressed on 9c37440: RequiresForfeit guards the on-chain kind with a test, and the down-migration test runs on postgres too. The follow-up review on 2026-09-02 confirmed both. Dismissing the stale request.

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 41+ days without a review. @bitcoin-coder-bob — VtxoKind discriminator: is this waiting on other PRs in the series to land first?

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 41 days without review. @bitcoin-coder-bob is the VtxoKind discriminator still moving forward?

bitcoin-coder-bob added a commit that referenced this pull request Sep 8, 2026
An on-chain Arkade UTXO has no batch behind it and so no expiry, and stores a
zero rather than a timestamp that would not be true. #1161 stopped IsExpired
reading that zero as 1970 and marking the vtxo permanently expired. Three places
read the field directly rather than through the predicate, and this covers them.

The one that bites is the minimum-expiry loop in SubmitOffchainTx. It walks the
spent vtxos for the soonest expiry, starting from MaxInt64, and carries the
matching root commitment txid along with it. A zero is smaller than any real
deadline, so a single on-chain input would win outright, date the resulting vtxo
to the epoch, and take the wrong commitment txid with it. Extracted as
earliestBatchExpiry so the rule is testable rather than buried in a long method.

The fee manager is the second. OffchainInput already treats the zero time as "no
expiry" and omits the CEL variable, but converting a zero ExpiresAt with
time.Unix produces 1970, which is not zero and hands the fee expression a
deadline in the past. It now passes the zero time through, so the expression sees
no expiry at all.

The settlement expiry gap is the third and is deliberately no more than intent:
a zero already fails that comparison, so the early return changes no outcome
today. It is there so reversing the comparison later cannot silently start
rejecting on-chain vtxos, and its comment and test both say so rather than
claiming a fix.

HasBatchExpiry names the condition once instead of repeating a kind check across
three packages.
@arkana-ai-bot

Copy link
Copy Markdown

This PR (domain,db: add VtxoKind discriminator for on-chain Arkade UTXOs) has been open for 3+ days without review. @bitcoin-coder-bob is anyone looking at this?

The VtxoKind doc comment carried an issue number, which belongs in the commit
message and the PR description rather than in the source.

Two comments in the down-migration test used a colon where a sentence reads
better, one of them as a bare Up label whose Down counterpart was already
written as prose.

The test's support constants sat above the test. Moved to the bottom with the
rest of the scaffolding, so the file opens on the test.

@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 cosmetic commit since 9c37440, 0 prior findings, 0 remain.


Prior findings

My previous pass (sha fc48791a) raised no issues. Nothing to close out.


Incremental diff — commit 3bf9080

Single commit: "domain,db: drop an issue reference and tidy the migration test layout."

Two files changed, purely cosmetic:

internal/core/domain/vtxo.go (+2/−2)
Removed the inline issue reference (issue #1159) from the VtxoKind doc comment and reflowed the sentence. No behavioural change.

internal/infrastructure/db/vtxo_kind_down_test.go (+11/−11)

  • Moved the const block (addVtxoKindMigrationVersion, addVtxoKindPostgresDsn) from the top of the file to the bottom alongside the rest of the test scaffolding.
  • Rephrased two comments from colon-label style to sentence prose.

No logic, schema, protocol, or interface changes in this commit. No new findings.


Summary

This commit is housekeeping only. All protocol logic reviewed in the previous pass is unchanged. No new issues.

…tions

The audits that produced the earlier comment cleanups only ever looked at Go
files, so the SQL comments in both backends kept an issue number and two colon
sentences.

Same wording in postgres and sqlite, since the pair should stay readable side by
side.

@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 — 3bf9080…54adb21. One cosmetic commit (SQL comment rewording only). No functional change.

Prior issues from the substantive PROTOCOL-CRITICAL review (the one now marked dismissed): 3 of 5 resolved, 2 still open.


Prior-issue closure

1. 🔴 RequiresForfeit() misfires on VtxoKindOnchainFixed

internal/core/domain/vtxo.go: The guard v.Kind != VtxoKindOnchain && was added before the existing !v.Swept && !v.IsNote() && !v.Unrolled expression. TestVtxo_RequiresForfeit covers the onchain-kind case. Resolved.

2. 🟡 Postgres down-migration has no automated roundtrip test — Fixed

internal/infrastructure/db/vtxo_kind_down_test.go: TestAddVtxoKindDownMigration now has both t.Run("sqlite", …) and t.Run("postgres", …) sub-tests, with newVtxoKindPostgresMigrator exercising the full apply/rollback/reapply cycle on a real Postgres instance. Resolved.

3. 🟡 combinedRowToVtxo did not carry KindFixed

internal/infrastructure/db/postgres/round_repo.go:727 and internal/infrastructure/db/sqlite/round_repo.go:869: Both now assign Kind: domain.VtxoKind(row.VtxoKind.Int32) / .Int64. The pre-existing Depth / MarkerIDs gap is acknowledged in the PR and unchanged by this PR — still open as a tracked pre-existing gap, not this PR's subject.

4. 🟡 No range-check on VtxoKind at deserialization — Still open

Every rowToVtxo path in both backends casts directly:

// postgres vtxo_repo.go, round_repo.go, marker_repo.go (multiple sites)
Kind: domain.VtxoKind(row.VtxoKind.Int32)

// sqlite vtxo_repo.go, round_repo.go, marker_repo.go (multiple sites)
Kind: domain.VtxoKind(row.VtxoKind.Int64)

An out-of-range integer stored by a future migration or direct SQL edit silently becomes an undefined VtxoKind, bypassing all IsNote() / IsExpired() / RequiresForfeit() guards. The new commit does not touch this. A minimal guard (switch or range check returning an error or defaulting to VtxoKindOffchain + logging) would eliminate the silent misclassification risk.

5. 🟡 SQLite down migration: DROP COLUMN without IF EXISTSStill open

internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.down.sql:7:

ALTER TABLE vtxo DROP COLUMN vtxo_kind;

The Postgres counterpart uses DROP COLUMN IF EXISTS vtxo_kind. SQLite ≥ 3.35.0 supports IF EXISTS on DROP COLUMN; omitting it makes the SQLite down migration non-idempotent. If a rollback is interrupted after the column is dropped but before the migration version is updated, re-running the down migration fails instead of recovering gracefully. The new commit only rewrites the opening comment; the DDL is unchanged.


New commit (54adb21)

SQL comments only — four migration files, two lines each, reformatting from colon-separated clauses to prose sentences. Zero functional change. No new findings.


Summary

The one blocking issue (RequiresForfeit() misfire) and the two actionable 🟡 items (Postgres roundtrip test, combinedRowToVtxo Kind wiring) are all resolved. The two remaining 🟡 items (no deserialization range-check, SQLite DROP COLUMN non-idempotence) are unchanged from the prior pass and neither is addressed by the new commit. Both are low-severity but worth tracking before the on-chain write path (#1159) goes live.

@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 — arkade-os/arkd #1161

domain,db: add VtxoKind discriminator for on-chain Arkade UTXOs (part of #1159)

⚠️ PROTOCOL-CRITICAL — VTXO domain type change + schema migration. Flagged for human sign-off.

Summary

Foundation slice for the on-chain Arkade UTXO feature. Adds the VtxoKind discriminator to the Vtxo domain struct and updates the three predicates that otherwise misclassify on-chain UTXOs.

What's correct

  • IsNote(): The kind check prevents an on-chain UTXO (which has no commitment txids) from being read as a note — correct.
  • RequiresForfeit(): On-chain UTXOs join a batch as boarding inputs, never as forfeitable vtxos — correct.
  • IsExpired(): An on-chain UTXO carries ExpiresAt == 0 (no batch), which without the guard would read as permanently expired — the guard is correct.
  • Postgres migration: vtxo_kind column added with a default of 0 (= VtxoKindOffchain), so existing rows are correctly classified without backfill. The views are rebuilt to expose the column. Down migration is included.
  • rowToVtxo* functions: Both scan paths updated to populate Kind from the DB row.
  • Test coverage: TestVtxo_IsNote, TestVtxo_IsExpired, and TestVtxo_RequiresForfeit each get a new fixture for the on-chain kind.

Minor question

The Badger and SQLite backends aren't shown in this diff — do they also persist VtxoKind, or is it only stored in Postgres? If the other backends use a different serialisation format they'd also need updating before on-chain UTXOs are written through them.

Verdict

Looks ready as a foundation slice. Confirm backend coverage before merging the slices that write VtxoKindOnchain records. Human review required due to schema change and VTXO domain impact.

@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 — VtxoKind discriminator for on-chain Arkade UTXOs

Verdict: looks correct and well-structured; protocol-critical schema change — please have a human reviewer confirm before merging.

What this does

Adds a Kind VtxoKind discriminator field to the Vtxo domain type, with two values: VtxoKindOffchain (0, default — existing behaviour) and VtxoKindOnchain (1). Existing rows default to 0 via the migration's DEFAULT 0, so no backfill is needed. Fixes three predicate methods that previously gave wrong answers for on-chain vtxos:

  • IsNote() — an on-chain vtxo has no commitment txids and would have been misread as a note.
  • RequiresForfeit() — an on-chain vtxo joins as a boarding input, never a forfeit.
  • IsExpired() — a zero ExpiresAt on an on-chain vtxo would have read as permanently expired.

Assessment

  • The DB migration (20260901000000_add_vtxo_kind) adds the column with NOT NULL DEFAULT 0 and reconstructs both views. The down migration is also present and consistent.
  • The open-enum comment explicitly reserves space for future sub-kinds without schema migrations, which is the right design given how PostgreSQL handles integer comparisons.
  • Test coverage covers all three fixed predicates for the on-chain case.
  • The rowToVtxoFromVtxoVw and rowToVtxoFromMarkerQuery functions in the Postgres layer now populate Kind. Check that the Badger and in-memory backends are also updated — not visible in this diff slice.

@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.

Flagged for human review — introduces a new VTXO kind that affects protocol-level classification.

This is the foundational PR for the VtxoKindOnchain feature. Key observations:

  • VtxoKind is defined as uint8 with iota, defaulting to VtxoKindOffchain = 0. Existing vtxos default to 0 in both the postgres and sqlite migrations (NOT NULL DEFAULT 0), so no backfill is needed and rollback is safe.
  • IsNote(), RequiresForfeit(), and IsExpired() are all correctly guarded for VtxoKindOnchain. The IsExpired guard is especially important: without it, a zero ExpiresAt would read as permanently expired.
  • Both postgres and sqlite migrations are present and symmetric. The down migrations reconstruct views without vtxo_kind. The postgres DROP VIEW IF EXISTS ordering is correct.
  • Generated SQLC code (models.go, query.sql.go) is updated consistently across both backends.
  • rowToVtxoFromVtxoVw, rowToVtxoFromMarkerQuery, and combinedRowToVtxo in round_repo.go all correctly populate Kind.

Minor note: VtxoKind is described as an open enum but the type is uint8. Future sub-kinds would simply occupy additional iota values. Worth a quick grep to confirm no exhaustive switch (without default) would silently mis-classify future kinds — none were found in this diff.

No security issues found. Looks ready to merge after human sign-off.

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