From b22641b8aadf1cb9e58bb8f0ba1bd22c029bc226 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:55:48 -0400 Subject: [PATCH 1/7] domain,db: add VtxoKind discriminator for on-chain Arkade UTXOs 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. --- internal/core/domain/vtxo.go | 17 +++- internal/core/domain/vtxo_test.go | 9 ++ .../20260724000000_add_vtxo_kind.down.sql | 69 ++++++++++++++++ .../20260724000000_add_vtxo_kind.up.sql | 72 ++++++++++++++++ .../db/postgres/sqlc/queries/models.go | 3 + .../db/postgres/sqlc/queries/query.sql.go | 54 ++++++++---- .../infrastructure/db/postgres/sqlc/query.sql | 7 +- .../infrastructure/db/postgres/vtxo_repo.go | 6 +- .../20260724000000_add_vtxo_kind.down.sql | 77 +++++++++++++++++ .../20260724000000_add_vtxo_kind.up.sql | 81 ++++++++++++++++++ .../db/sqlite/sqlc/queries/models.go | 3 + .../db/sqlite/sqlc/queries/query.sql.go | 53 ++++++++---- .../infrastructure/db/sqlite/sqlc/query.sql | 7 +- .../infrastructure/db/sqlite/vtxo_repo.go | 6 +- .../infrastructure/db/vtxo_kind_down_test.go | 82 +++++++++++++++++++ 15 files changed, 499 insertions(+), 47 deletions(-) create mode 100644 internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.down.sql create mode 100644 internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.up.sql create mode 100644 internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.down.sql create mode 100644 internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.up.sql create mode 100644 internal/infrastructure/db/vtxo_kind_down_test.go diff --git a/internal/core/domain/vtxo.go b/internal/core/domain/vtxo.go index d03013031..33d8bd87b 100644 --- a/internal/core/domain/vtxo.go +++ b/internal/core/domain/vtxo.go @@ -65,8 +65,20 @@ type Vtxo struct { Depth uint32 // chain depth: 0 for vtxos from batch, increments on each chain MarkerIDs []string // marker IDs for DAG traversal optimization (supports multiple parent markers) Assets []AssetDenomination + Kind VtxoKind // how the vtxo is held (offchain by default, onchain for on-chain Arkade UTXOs) } +// VtxoKind distinguishes how a vtxo is held. Offchain (the default) is a batch +// leaf or an offchain-tx output. Onchain marks a vtxo held in an on-chain +// Arkade UTXO (issue #1159). It is an open enum so future on-chain sub-kinds can +// be added without another schema migration. +type VtxoKind uint8 + +const ( + VtxoKindOffchain VtxoKind = iota + VtxoKindOnchain +) + func (v Vtxo) String() string { // nolint b, _ := json.MarshalIndent(v, "", " ") @@ -74,7 +86,10 @@ func (v Vtxo) String() string { } func (v Vtxo) IsNote() bool { - return len(v.CommitmentTxids) <= 0 && v.RootCommitmentTxid == "" + // An on-chain Arkade UTXO also has no commitment txids, so the kind check + // keeps it from reading as a note. + return v.Kind != VtxoKindOnchain && + len(v.CommitmentTxids) <= 0 && v.RootCommitmentTxid == "" } func (v Vtxo) RequiresForfeit() bool { diff --git a/internal/core/domain/vtxo_test.go b/internal/core/domain/vtxo_test.go index 7145d46dc..4c7580644 100644 --- a/internal/core/domain/vtxo_test.go +++ b/internal/core/domain/vtxo_test.go @@ -66,6 +66,15 @@ func TestVtxo_IsNote(t *testing.T) { }, isNote: false, }, + { + // An on-chain Arkade UTXO has no commitment txids either, so + // the kind discriminator must keep it from reading as a note. + name: "onchain kind is not a note despite empty commitments", + vtxo: domain.Vtxo{ + Kind: domain.VtxoKindOnchain, + }, + isNote: false, + }, } for _, f := range fixtures { t.Run(f.name, func(t *testing.T) { diff --git a/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.down.sql b/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.down.sql new file mode 100644 index 000000000..1e6a3094d --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.down.sql @@ -0,0 +1,69 @@ +-- Reverse add_vtxo_kind: drop the views, drop the column, recreate the views +-- without vtxo_kind. +DROP VIEW IF EXISTS intent_with_inputs_vw; +DROP VIEW IF EXISTS vtxo_vw; + +ALTER TABLE vtxo DROP COLUMN IF EXISTS vtxo_kind; + +CREATE VIEW vtxo_vw AS +SELECT v.*, + COALESCE(vc.commitments, '') AS commitments, + ( + EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(v.markers) AS m(marker_id) + JOIN swept_marker sm ON sm.marker_id = m.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount +FROM vtxo v +LEFT JOIN LATERAL ( + SELECT string_agg(commitment_txid, ',') AS commitments + FROM vtxo_commitment_txid + WHERE vtxo_txid = v.txid AND vtxo_vout = v.vout +) vc ON true +LEFT JOIN ( + SELECT txid, vout, asset_id, amount + FROM asset_projection + GROUP BY txid, vout, asset_id, amount +) ap +ON ap.txid = v.txid AND ap.vout = v.vout; + +CREATE VIEW intent_with_inputs_vw AS +SELECT + v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, + v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, + v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, + COALESCE(vc.commitments, '') AS commitments, + ( + EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(v.markers) AS m(marker_id) + JOIN swept_marker sm ON sm.marker_id = m.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount, + intent.id, intent.round_id, intent.proof, intent.message, + intent.txid AS intent_txid +FROM intent +LEFT OUTER JOIN vtxo v ON intent.id = v.intent_id +LEFT JOIN LATERAL ( + SELECT string_agg(commitment_txid, ',') AS commitments + FROM vtxo_commitment_txid + WHERE vtxo_txid = v.txid AND vtxo_vout = v.vout +) vc ON true +LEFT JOIN ( + SELECT txid, vout, asset_id, amount + FROM asset_projection + GROUP BY txid, vout, asset_id, amount +) ap ON ap.txid = v.txid AND ap.vout = v.vout; diff --git a/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.up.sql b/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.up.sql new file mode 100644 index 000000000..93cf9b2e4 --- /dev/null +++ b/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.up.sql @@ -0,0 +1,72 @@ +-- Add the vtxo_kind discriminator: 0 = offchain (batch leaf or offchain-tx +-- output), 1 = onchain (on-chain Arkade UTXO, issue #1159). Existing rows +-- default to offchain, so no backfill is needed. +ALTER TABLE vtxo ADD COLUMN IF NOT EXISTS vtxo_kind INTEGER NOT NULL DEFAULT 0; + +-- Recreate the views so vtxo_kind is visible. vtxo_vw is SELECT v.* and picks it +-- up on recreation; intent_with_inputs_vw enumerates columns, so add it by hand. +DROP VIEW IF EXISTS intent_with_inputs_vw; +DROP VIEW IF EXISTS vtxo_vw; + +CREATE VIEW vtxo_vw AS +SELECT v.*, + COALESCE(vc.commitments, '') AS commitments, + ( + EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(v.markers) AS m(marker_id) + JOIN swept_marker sm ON sm.marker_id = m.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount +FROM vtxo v +LEFT JOIN LATERAL ( + SELECT string_agg(commitment_txid, ',') AS commitments + FROM vtxo_commitment_txid + WHERE vtxo_txid = v.txid AND vtxo_vout = v.vout +) vc ON true +LEFT JOIN ( + SELECT txid, vout, asset_id, amount + FROM asset_projection + GROUP BY txid, vout, asset_id, amount +) ap +ON ap.txid = v.txid AND ap.vout = v.vout; + +CREATE VIEW intent_with_inputs_vw AS +SELECT + v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, + v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, + v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.vtxo_kind, + COALESCE(vc.commitments, '') AS commitments, + ( + EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(v.markers) AS m(marker_id) + JOIN swept_marker sm ON sm.marker_id = m.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount, + intent.id, intent.round_id, intent.proof, intent.message, + intent.txid AS intent_txid +FROM intent +LEFT OUTER JOIN vtxo v ON intent.id = v.intent_id +LEFT JOIN LATERAL ( + SELECT string_agg(commitment_txid, ',') AS commitments + FROM vtxo_commitment_txid + WHERE vtxo_txid = v.txid AND vtxo_vout = v.vout +) vc ON true +LEFT JOIN ( + SELECT txid, vout, asset_id, amount + FROM asset_projection + GROUP BY txid, vout, asset_id, amount +) ap ON ap.txid = v.txid AND ap.vout = v.vout; diff --git a/internal/infrastructure/db/postgres/sqlc/queries/models.go b/internal/infrastructure/db/postgres/sqlc/queries/models.go index 1669c7b70..f63d1e147 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/models.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/models.go @@ -81,6 +81,7 @@ type IntentWithInputsVw struct { UpdatedAt sql.NullInt64 Depth sql.NullInt32 Markers pqtype.NullRawMessage + VtxoKind sql.NullInt32 Commitments []byte Swept sql.NullBool AssetID sql.NullString @@ -301,6 +302,7 @@ type Vtxo struct { UpdatedAt int64 Depth int32 Markers json.RawMessage + VtxoKind int32 } type VtxoCommitmentTxid struct { @@ -327,6 +329,7 @@ type VtxoVw struct { UpdatedAt int64 Depth int32 Markers json.RawMessage + VtxoKind int32 Commitments []byte Swept sql.NullBool AssetID string diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index 784d219c1..0de484fe7 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -206,7 +206,7 @@ func (q *Queries) SelectAllRoundIds(ctx context.Context) ([]string, error) { } const selectAllVtxos = `-- name: SelectAllVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw ` type SelectAllVtxosRow struct { @@ -240,6 +240,7 @@ func (q *Queries) SelectAllVtxos(ctx context.Context) ([]SelectAllVtxosRow, erro &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -685,7 +686,7 @@ func (q *Queries) SelectMarkersByIds(ctx context.Context, ids []string) ([]Marke } const selectNotUnrolledVtxos = `-- name: SelectNotUnrolledVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false ` type SelectNotUnrolledVtxosRow struct { @@ -719,6 +720,7 @@ func (q *Queries) SelectNotUnrolledVtxos(ctx context.Context) ([]SelectNotUnroll &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -738,7 +740,7 @@ func (q *Queries) SelectNotUnrolledVtxos(ctx context.Context) ([]SelectNotUnroll } const selectNotUnrolledVtxosWithPubkey = `-- name: SelectNotUnrolledVtxosWithPubkey :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false AND pubkey = $1 +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false AND pubkey = $1 ` type SelectNotUnrolledVtxosWithPubkeyRow struct { @@ -772,6 +774,7 @@ func (q *Queries) SelectNotUnrolledVtxosWithPubkey(ctx context.Context, pubkey s &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -881,7 +884,7 @@ func (q *Queries) SelectOffchainTxsByTxids(ctx context.Context, txids []string) } const selectPendingSpentVtxo = `-- name: SelectPendingSpentVtxo :many -SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.commitments, v.swept, v.asset_id, v.asset_amount +SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.vtxo_kind, v.commitments, v.swept, v.asset_id, v.asset_amount FROM vtxo_vw v WHERE v.txid = $1 AND v.vout = $2 AND v.spent = TRUE AND v.unrolled = FALSE and COALESCE(v.settled_by, '') = '' @@ -922,6 +925,7 @@ func (q *Queries) SelectPendingSpentVtxo(ctx context.Context, arg SelectPendingS &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -941,7 +945,7 @@ func (q *Queries) SelectPendingSpentVtxo(ctx context.Context, arg SelectPendingS } const selectPendingSpentVtxosWithPubkeys = `-- name: SelectPendingSpentVtxosWithPubkeys :many -SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.commitments, v.swept, v.asset_id, v.asset_amount +SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.vtxo_kind, v.commitments, v.swept, v.asset_id, v.asset_amount FROM vtxo_vw v WHERE v.spent = TRUE AND v.unrolled = FALSE and COALESCE(v.settled_by, '') = '' AND v.pubkey = ANY($1::varchar[]) @@ -985,6 +989,7 @@ func (q *Queries) SelectPendingSpentVtxosWithPubkeys(ctx context.Context, arg Se &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -1321,7 +1326,7 @@ func (q *Queries) SelectRoundVtxoTree(ctx context.Context, txid string) ([]Tx, e } const selectRoundVtxoTreeLeaves = `-- name: SelectRoundVtxoTreeLeaves :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE commitment_txid = $1 AND preconfirmed = false +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE commitment_txid = $1 AND preconfirmed = false ` type SelectRoundVtxoTreeLeavesRow struct { @@ -1355,6 +1360,7 @@ func (q *Queries) SelectRoundVtxoTreeLeaves(ctx context.Context, commitmentTxid &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -1378,7 +1384,7 @@ SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round_intents_vw.id, round_intents_vw.round_id, round_intents_vw.proof, round_intents_vw.message, round_intents_vw.txid, round_txs_vw.txid, round_txs_vw.tx, round_txs_vw.round_id, round_txs_vw.type, round_txs_vw.position, round_txs_vw.children, intent_with_receivers_vw.intent_id, intent_with_receivers_vw.pubkey, intent_with_receivers_vw.onchain_address, intent_with_receivers_vw.amount, intent_with_receivers_vw.id, intent_with_receivers_vw.round_id, intent_with_receivers_vw.proof, intent_with_receivers_vw.message, intent_with_receivers_vw.txid, - intent_with_inputs_vw.txid, intent_with_inputs_vw.vout, intent_with_inputs_vw.pubkey, intent_with_inputs_vw.amount, intent_with_inputs_vw.expires_at, intent_with_inputs_vw.created_at, intent_with_inputs_vw.commitment_txid, intent_with_inputs_vw.spent_by, intent_with_inputs_vw.spent, intent_with_inputs_vw.unrolled, intent_with_inputs_vw.preconfirmed, intent_with_inputs_vw.settled_by, intent_with_inputs_vw.ark_txid, intent_with_inputs_vw.intent_id, intent_with_inputs_vw.updated_at, intent_with_inputs_vw.depth, intent_with_inputs_vw.markers, intent_with_inputs_vw.commitments, intent_with_inputs_vw.swept, intent_with_inputs_vw.asset_id, intent_with_inputs_vw.asset_amount, intent_with_inputs_vw.id, intent_with_inputs_vw.round_id, intent_with_inputs_vw.proof, intent_with_inputs_vw.message, intent_with_inputs_vw.intent_txid + intent_with_inputs_vw.txid, intent_with_inputs_vw.vout, intent_with_inputs_vw.pubkey, intent_with_inputs_vw.amount, intent_with_inputs_vw.expires_at, intent_with_inputs_vw.created_at, intent_with_inputs_vw.commitment_txid, intent_with_inputs_vw.spent_by, intent_with_inputs_vw.spent, intent_with_inputs_vw.unrolled, intent_with_inputs_vw.preconfirmed, intent_with_inputs_vw.settled_by, intent_with_inputs_vw.ark_txid, intent_with_inputs_vw.intent_id, intent_with_inputs_vw.updated_at, intent_with_inputs_vw.depth, intent_with_inputs_vw.markers, intent_with_inputs_vw.vtxo_kind, intent_with_inputs_vw.commitments, intent_with_inputs_vw.swept, intent_with_inputs_vw.asset_id, intent_with_inputs_vw.asset_amount, intent_with_inputs_vw.id, intent_with_inputs_vw.round_id, intent_with_inputs_vw.proof, intent_with_inputs_vw.message, intent_with_inputs_vw.intent_txid FROM round LEFT OUTER JOIN round_intents_vw ON round.id=round_intents_vw.round_id LEFT OUTER JOIN round_txs_vw ON round.id=round_txs_vw.round_id @@ -1454,6 +1460,7 @@ func (q *Queries) SelectRoundWithId(ctx context.Context, id string) ([]SelectRou &i.IntentWithInputsVw.UpdatedAt, &i.IntentWithInputsVw.Depth, &i.IntentWithInputsVw.Markers, + &i.IntentWithInputsVw.VtxoKind, &i.IntentWithInputsVw.Commitments, &i.IntentWithInputsVw.Swept, &i.IntentWithInputsVw.AssetID, @@ -1482,7 +1489,7 @@ SELECT round.id, round.starting_timestamp, round.ending_timestamp, round.ended, round_intents_vw.id, round_intents_vw.round_id, round_intents_vw.proof, round_intents_vw.message, round_intents_vw.txid, round_txs_vw.txid, round_txs_vw.tx, round_txs_vw.round_id, round_txs_vw.type, round_txs_vw.position, round_txs_vw.children, intent_with_receivers_vw.intent_id, intent_with_receivers_vw.pubkey, intent_with_receivers_vw.onchain_address, intent_with_receivers_vw.amount, intent_with_receivers_vw.id, intent_with_receivers_vw.round_id, intent_with_receivers_vw.proof, intent_with_receivers_vw.message, intent_with_receivers_vw.txid, - intent_with_inputs_vw.txid, intent_with_inputs_vw.vout, intent_with_inputs_vw.pubkey, intent_with_inputs_vw.amount, intent_with_inputs_vw.expires_at, intent_with_inputs_vw.created_at, intent_with_inputs_vw.commitment_txid, intent_with_inputs_vw.spent_by, intent_with_inputs_vw.spent, intent_with_inputs_vw.unrolled, intent_with_inputs_vw.preconfirmed, intent_with_inputs_vw.settled_by, intent_with_inputs_vw.ark_txid, intent_with_inputs_vw.intent_id, intent_with_inputs_vw.updated_at, intent_with_inputs_vw.depth, intent_with_inputs_vw.markers, intent_with_inputs_vw.commitments, intent_with_inputs_vw.swept, intent_with_inputs_vw.asset_id, intent_with_inputs_vw.asset_amount, intent_with_inputs_vw.id, intent_with_inputs_vw.round_id, intent_with_inputs_vw.proof, intent_with_inputs_vw.message, intent_with_inputs_vw.intent_txid + intent_with_inputs_vw.txid, intent_with_inputs_vw.vout, intent_with_inputs_vw.pubkey, intent_with_inputs_vw.amount, intent_with_inputs_vw.expires_at, intent_with_inputs_vw.created_at, intent_with_inputs_vw.commitment_txid, intent_with_inputs_vw.spent_by, intent_with_inputs_vw.spent, intent_with_inputs_vw.unrolled, intent_with_inputs_vw.preconfirmed, intent_with_inputs_vw.settled_by, intent_with_inputs_vw.ark_txid, intent_with_inputs_vw.intent_id, intent_with_inputs_vw.updated_at, intent_with_inputs_vw.depth, intent_with_inputs_vw.markers, intent_with_inputs_vw.vtxo_kind, intent_with_inputs_vw.commitments, intent_with_inputs_vw.swept, intent_with_inputs_vw.asset_id, intent_with_inputs_vw.asset_amount, intent_with_inputs_vw.id, intent_with_inputs_vw.round_id, intent_with_inputs_vw.proof, intent_with_inputs_vw.message, intent_with_inputs_vw.intent_txid FROM round LEFT OUTER JOIN round_intents_vw ON round.id=round_intents_vw.round_id LEFT OUTER JOIN round_txs_vw ON round.id=round_txs_vw.round_id @@ -1560,6 +1567,7 @@ func (q *Queries) SelectRoundWithTxid(ctx context.Context, txid string) ([]Selec &i.IntentWithInputsVw.UpdatedAt, &i.IntentWithInputsVw.Depth, &i.IntentWithInputsVw.Markers, + &i.IntentWithInputsVw.VtxoKind, &i.IntentWithInputsVw.Commitments, &i.IntentWithInputsVw.Swept, &i.IntentWithInputsVw.AssetID, @@ -1692,7 +1700,7 @@ func (q *Queries) SelectSweepableRounds(ctx context.Context) ([]string, error) { } const selectSweepableUnrolledVtxos = `-- name: SelectSweepableUnrolledVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE spent = true AND unrolled = true AND swept = false AND COALESCE(settled_by, '') = '' +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE spent = true AND unrolled = true AND swept = false AND COALESCE(settled_by, '') = '' ` type SelectSweepableUnrolledVtxosRow struct { @@ -1726,6 +1734,7 @@ func (q *Queries) SelectSweepableUnrolledVtxos(ctx context.Context) ([]SelectSwe &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -1872,7 +1881,7 @@ func (q *Queries) SelectTxs(ctx context.Context, dollar_1 []string) ([]SelectTxs } const selectVtxo = `-- name: SelectVtxo :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE txid = $1 AND vout = $2 +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE txid = $1 AND vout = $2 ` type SelectVtxoParams struct { @@ -1911,6 +1920,7 @@ func (q *Queries) SelectVtxo(ctx context.Context, arg SelectVtxoParams) ([]Selec &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -1930,7 +1940,7 @@ func (q *Queries) SelectVtxo(ctx context.Context, arg SelectVtxoParams) ([]Selec } const selectVtxoChainByMarker = `-- name: SelectVtxoChainByMarker :many -SELECT txid, vout, pubkey, amount, expires_at, created_at, commitment_txid, spent_by, spent, unrolled, preconfirmed, settled_by, ark_txid, intent_id, updated_at, depth, markers, commitments, swept, asset_id, asset_amount FROM vtxo_vw +SELECT txid, vout, pubkey, amount, expires_at, created_at, commitment_txid, spent_by, spent, unrolled, preconfirmed, settled_by, ark_txid, intent_id, updated_at, depth, markers, vtxo_kind, commitments, swept, asset_id, asset_amount FROM vtxo_vw WHERE markers ?| $1::TEXT[] ORDER BY depth DESC ` @@ -1963,6 +1973,7 @@ func (q *Queries) SelectVtxoChainByMarker(ctx context.Context, markerIds []strin &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -2065,7 +2076,7 @@ func (q *Queries) SelectVtxoPubKeysByCommitmentTxids(ctx context.Context, arg Se } const selectVtxosByArkTxid = `-- name: SelectVtxosByArkTxid :many -SELECT txid, vout, pubkey, amount, expires_at, created_at, commitment_txid, spent_by, spent, unrolled, preconfirmed, settled_by, ark_txid, intent_id, updated_at, depth, markers, commitments, swept, asset_id, asset_amount FROM vtxo_vw WHERE ark_txid = $1 +SELECT txid, vout, pubkey, amount, expires_at, created_at, commitment_txid, spent_by, spent, unrolled, preconfirmed, settled_by, ark_txid, intent_id, updated_at, depth, markers, vtxo_kind, commitments, swept, asset_id, asset_amount FROM vtxo_vw WHERE ark_txid = $1 ` // Get all VTXOs created by a specific ark tx (offchain tx) @@ -2096,6 +2107,7 @@ func (q *Queries) SelectVtxosByArkTxid(ctx context.Context, arkTxid sql.NullStri &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -2116,7 +2128,7 @@ func (q *Queries) SelectVtxosByArkTxid(ctx context.Context, arkTxid sql.NullStri const selectVtxosByDepthRange = `-- name: SelectVtxosByDepthRange :many -SELECT txid, vout, pubkey, amount, expires_at, created_at, commitment_txid, spent_by, spent, unrolled, preconfirmed, settled_by, ark_txid, intent_id, updated_at, depth, markers, commitments, swept, asset_id, asset_amount FROM vtxo_vw +SELECT txid, vout, pubkey, amount, expires_at, created_at, commitment_txid, spent_by, spent, unrolled, preconfirmed, settled_by, ark_txid, intent_id, updated_at, depth, markers, vtxo_kind, commitments, swept, asset_id, asset_amount FROM vtxo_vw WHERE depth >= $1 AND depth <= $2 ORDER BY depth DESC ` @@ -2155,6 +2167,7 @@ func (q *Queries) SelectVtxosByDepthRange(ctx context.Context, arg SelectVtxosBy &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -2174,7 +2187,7 @@ func (q *Queries) SelectVtxosByDepthRange(ctx context.Context, arg SelectVtxosBy } const selectVtxosByMarkerId = `-- name: SelectVtxosByMarkerId :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE markers @> jsonb_build_array($1::TEXT) +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE markers @> jsonb_build_array($1::TEXT) ` type SelectVtxosByMarkerIdRow struct { @@ -2209,6 +2222,7 @@ func (q *Queries) SelectVtxosByMarkerId(ctx context.Context, markerID string) ([ &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2298,7 +2312,7 @@ func (q *Queries) SelectVtxosOutpointsByArkTxidRecursive(ctx context.Context, ar } const selectVtxosWithPubkeys = `-- name: SelectVtxosWithPubkeys :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE vtxo_vw.pubkey = ANY($1::varchar[]) AND vtxo_vw.updated_at >= $2::bigint AND ($3::bigint = 0 OR vtxo_vw.updated_at <= $3::bigint) @@ -2341,6 +2355,7 @@ func (q *Queries) SelectVtxosWithPubkeys(ctx context.Context, arg SelectVtxosWit &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2916,11 +2931,11 @@ func (q *Queries) UpsertTx(ctx context.Context, arg UpsertTxParams) error { const upsertVtxo = `-- name: UpsertVtxo :exec INSERT INTO vtxo ( txid, vout, pubkey, amount, commitment_txid, settled_by, ark_txid, - spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers + spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers, vtxo_kind ) VALUES ( $1, $2, $3, $4, $5, $6, $7, - $8, $9, $10, $11, $12, $13, (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, $14, $15::jsonb + $8, $9, $10, $11, $12, $13, (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, $14, $15::jsonb, $16 ) ON CONFLICT(txid, vout) DO UPDATE SET pubkey = EXCLUDED.pubkey, amount = EXCLUDED.amount, @@ -2935,7 +2950,8 @@ VALUES ( created_at = EXCLUDED.created_at, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, depth = EXCLUDED.depth, - markers = EXCLUDED.markers + markers = EXCLUDED.markers, + vtxo_kind = EXCLUDED.vtxo_kind ` type UpsertVtxoParams struct { @@ -2954,6 +2970,7 @@ type UpsertVtxoParams struct { CreatedAt int64 Depth int32 Markers json.RawMessage + VtxoKind int32 } func (q *Queries) UpsertVtxo(ctx context.Context, arg UpsertVtxoParams) error { @@ -2973,6 +2990,7 @@ func (q *Queries) UpsertVtxo(ctx context.Context, arg UpsertVtxoParams) error { arg.CreatedAt, arg.Depth, arg.Markers, + arg.VtxoKind, ) return err } diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index d8462c833..4391952f4 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -49,11 +49,11 @@ ON CONFLICT(intent_id, pubkey, onchain_address) DO UPDATE SET -- name: UpsertVtxo :exec INSERT INTO vtxo ( txid, vout, pubkey, amount, commitment_txid, settled_by, ark_txid, - spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers + spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers, vtxo_kind ) VALUES ( @txid, @vout, @pubkey, @amount, @commitment_txid, @settled_by, @ark_txid, - @spent_by, @spent, @unrolled, @preconfirmed, @expires_at, @created_at, (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, @depth, @markers::jsonb + @spent_by, @spent, @unrolled, @preconfirmed, @expires_at, @created_at, (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, @depth, @markers::jsonb, @vtxo_kind ) ON CONFLICT(txid, vout) DO UPDATE SET pubkey = EXCLUDED.pubkey, amount = EXCLUDED.amount, @@ -68,7 +68,8 @@ VALUES ( created_at = EXCLUDED.created_at, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, depth = EXCLUDED.depth, - markers = EXCLUDED.markers; + markers = EXCLUDED.markers, + vtxo_kind = EXCLUDED.vtxo_kind; -- name: InsertVtxoCommitmentTxid :exec INSERT INTO vtxo_commitment_txid (vtxo_txid, vtxo_vout, commitment_txid) diff --git a/internal/infrastructure/db/postgres/vtxo_repo.go b/internal/infrastructure/db/postgres/vtxo_repo.go index 019f9d615..17c70a99b 100644 --- a/internal/infrastructure/db/postgres/vtxo_repo.go +++ b/internal/infrastructure/db/postgres/vtxo_repo.go @@ -74,8 +74,9 @@ func (v *vtxoRepository) AddVtxos(ctx context.Context, vtxos []domain.Vtxo) erro ArkTxid: sql.NullString{ String: vtxo.ArkTxid, Valid: len(vtxo.ArkTxid) > 0, }, - Depth: int32(vtxo.Depth), - Markers: markersJSON, + Depth: int32(vtxo.Depth), + Markers: markersJSON, + VtxoKind: int32(vtxo.Kind), }, ); err != nil { return err @@ -582,6 +583,7 @@ func rowToVtxo(row queries.VtxoVw) domain.Vtxo { Depth: uint32(row.Depth), MarkerIDs: parseMarkersJSONBFromVtxo(row.Markers), Assets: assets, + Kind: domain.VtxoKind(row.VtxoKind), } } diff --git a/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.down.sql b/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.down.sql new file mode 100644 index 000000000..93a9de316 --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.down.sql @@ -0,0 +1,77 @@ +-- Reverse add_vtxo_kind: drop the views, drop the column, recreate the views +-- without vtxo_kind. +DROP VIEW IF EXISTS intent_with_inputs_vw; +DROP VIEW IF EXISTS vtxo_vw; + +ALTER TABLE vtxo DROP COLUMN vtxo_kind; + +CREATE VIEW vtxo_vw AS +SELECT v.*, + COALESCE(( + SELECT group_concat(commitment_txid, ',') + FROM vtxo_commitment_txid + WHERE vtxo_txid = v.txid AND vtxo_vout = v.vout + ), '') AS commitments, + ( + EXISTS ( + SELECT 1 FROM swept_marker sm + JOIN json_each(v.markers) j ON j.value = sm.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount +FROM vtxo v +LEFT JOIN ( + SELECT DISTINCT txid, vout, asset_id, amount + FROM asset_projection +) AS ap +ON ap.txid = v.txid AND ap.vout = v.vout; + +CREATE VIEW intent_with_inputs_vw AS +SELECT + v.txid, + v.vout, + v.pubkey, + v.amount, + v.expires_at, + v.created_at, + v.commitment_txid, + v.spent_by, + v.spent, + v.unrolled, + v.preconfirmed, + v.settled_by, + v.ark_txid, + v.intent_id, + v.updated_at, + v.depth, + v.markers, + COALESCE(( + SELECT group_concat(vc.commitment_txid) + FROM vtxo_commitment_txid vc + WHERE vc.vtxo_txid = v.txid AND vc.vtxo_vout = v.vout + ), '') AS commitments, + ( + EXISTS ( + SELECT 1 FROM swept_marker sm + JOIN json_each(v.markers) j ON j.value = sm.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount, + intent.id, + intent.round_id, + intent.proof, + intent.message, + intent.txid AS intent_txid +FROM intent +LEFT OUTER JOIN vtxo v ON intent.id = v.intent_id +LEFT JOIN asset_projection ap ON v.txid = ap.txid AND v.vout = ap.vout; diff --git a/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.up.sql b/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.up.sql new file mode 100644 index 000000000..89338cc26 --- /dev/null +++ b/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.up.sql @@ -0,0 +1,81 @@ +-- Add the vtxo_kind discriminator: 0 = offchain (batch leaf or offchain-tx +-- output), 1 = onchain (on-chain Arkade UTXO, issue #1159). Existing rows +-- default to offchain, so no backfill is needed. +ALTER TABLE vtxo ADD COLUMN vtxo_kind INTEGER NOT NULL DEFAULT 0; + +-- Recreate the views so vtxo_kind is visible. vtxo_vw is SELECT v.* and picks it +-- up on recreation; intent_with_inputs_vw enumerates columns, so add it by hand. +DROP VIEW IF EXISTS intent_with_inputs_vw; +DROP VIEW IF EXISTS vtxo_vw; + +CREATE VIEW vtxo_vw AS +SELECT v.*, + COALESCE(( + SELECT group_concat(commitment_txid, ',') + FROM vtxo_commitment_txid + WHERE vtxo_txid = v.txid AND vtxo_vout = v.vout + ), '') AS commitments, + ( + EXISTS ( + SELECT 1 FROM swept_marker sm + JOIN json_each(v.markers) j ON j.value = sm.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount +FROM vtxo v +LEFT JOIN ( + SELECT DISTINCT txid, vout, asset_id, amount + FROM asset_projection +) AS ap +ON ap.txid = v.txid AND ap.vout = v.vout; + +CREATE VIEW intent_with_inputs_vw AS +SELECT + v.txid, + v.vout, + v.pubkey, + v.amount, + v.expires_at, + v.created_at, + v.commitment_txid, + v.spent_by, + v.spent, + v.unrolled, + v.preconfirmed, + v.settled_by, + v.ark_txid, + v.intent_id, + v.updated_at, + v.depth, + v.markers, + v.vtxo_kind, + COALESCE(( + SELECT group_concat(vc.commitment_txid) + FROM vtxo_commitment_txid vc + WHERE vc.vtxo_txid = v.txid AND vc.vtxo_vout = v.vout + ), '') AS commitments, + ( + EXISTS ( + SELECT 1 FROM swept_marker sm + JOIN json_each(v.markers) j ON j.value = sm.marker_id + ) + OR EXISTS ( + SELECT 1 FROM swept_vtxo sv + WHERE sv.txid = v.txid AND sv.vout = v.vout + ) + ) AS swept, + COALESCE(ap.asset_id, '') AS asset_id, + COALESCE(ap.amount, 0) AS asset_amount, + intent.id, + intent.round_id, + intent.proof, + intent.message, + intent.txid AS intent_txid +FROM intent +LEFT OUTER JOIN vtxo v ON intent.id = v.intent_id +LEFT JOIN asset_projection ap ON v.txid = ap.txid AND v.vout = ap.vout; diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/models.go b/internal/infrastructure/db/sqlite/sqlc/queries/models.go index b00a47a4d..accb7f22d 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/models.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/models.go @@ -78,6 +78,7 @@ type IntentWithInputsVw struct { UpdatedAt sql.NullInt64 Depth sql.NullInt64 Markers sql.NullString + VtxoKind sql.NullInt64 Commitments interface{} Swept interface{} AssetID string @@ -280,6 +281,7 @@ type Vtxo struct { UpdatedAt sql.NullInt64 Depth int64 Markers string + VtxoKind int64 } type VtxoCommitmentTxid struct { @@ -306,6 +308,7 @@ type VtxoVw struct { UpdatedAt sql.NullInt64 Depth int64 Markers string + VtxoKind int64 Commitments interface{} Swept interface{} AssetID string diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index ddff072a8..e66208c53 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -190,7 +190,7 @@ func (q *Queries) SelectAllRoundIds(ctx context.Context) ([]string, error) { } const selectAllVtxos = `-- name: SelectAllVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw ` type SelectAllVtxosRow struct { @@ -224,6 +224,7 @@ func (q *Queries) SelectAllVtxos(ctx context.Context) ([]SelectAllVtxosRow, erro &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -766,7 +767,7 @@ func (q *Queries) SelectMarkersByIds(ctx context.Context, ids []string) ([]Marke } const selectNotUnrolledVtxos = `-- name: SelectNotUnrolledVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false ` type SelectNotUnrolledVtxosRow struct { @@ -800,6 +801,7 @@ func (q *Queries) SelectNotUnrolledVtxos(ctx context.Context) ([]SelectNotUnroll &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -819,7 +821,7 @@ func (q *Queries) SelectNotUnrolledVtxos(ctx context.Context) ([]SelectNotUnroll } const selectNotUnrolledVtxosWithPubkey = `-- name: SelectNotUnrolledVtxosWithPubkey :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false AND pubkey = ?1 +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = false AND pubkey = ?1 ` type SelectNotUnrolledVtxosWithPubkeyRow struct { @@ -853,6 +855,7 @@ func (q *Queries) SelectNotUnrolledVtxosWithPubkey(ctx context.Context, pubkey s &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -972,7 +975,7 @@ func (q *Queries) SelectOffchainTxsByTxids(ctx context.Context, txids []string) } const selectPendingSpentVtxo = `-- name: SelectPendingSpentVtxo :many -SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.commitments, v.swept, v.asset_id, v.asset_amount +SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.vtxo_kind, v.commitments, v.swept, v.asset_id, v.asset_amount FROM vtxo_vw v WHERE v.txid = ?1 AND v.vout = ?2 AND v.spent = TRUE AND v.unrolled = FALSE AND COALESCE(v.settled_by, '') = '' @@ -1013,6 +1016,7 @@ func (q *Queries) SelectPendingSpentVtxo(ctx context.Context, arg SelectPendingS &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -1032,7 +1036,7 @@ func (q *Queries) SelectPendingSpentVtxo(ctx context.Context, arg SelectPendingS } const selectPendingSpentVtxosWithPubkeys = `-- name: SelectPendingSpentVtxosWithPubkeys :many -SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.commitments, v.swept, v.asset_id, v.asset_amount +SELECT v.txid, v.vout, v.pubkey, v.amount, v.expires_at, v.created_at, v.commitment_txid, v.spent_by, v.spent, v.unrolled, v.preconfirmed, v.settled_by, v.ark_txid, v.intent_id, v.updated_at, v.depth, v.markers, v.vtxo_kind, v.commitments, v.swept, v.asset_id, v.asset_amount FROM vtxo_vw v WHERE v.spent = TRUE AND v.unrolled = FALSE AND COALESCE(v.settled_by, '') = '' AND v.ark_txid IS NOT NULL AND NOT EXISTS ( @@ -1088,6 +1092,7 @@ func (q *Queries) SelectPendingSpentVtxosWithPubkeys(ctx context.Context, arg Se &i.UpdatedAt, &i.Depth, &i.Markers, + &i.VtxoKind, &i.Commitments, &i.Swept, &i.AssetID, @@ -1308,7 +1313,7 @@ SELECT r.ending_timestamp, ( SELECT COALESCE(SUM(amount), 0) FROM ( - SELECT DISTINCT v2.txid, v2.vout, v2.pubkey, v2.amount, v2.expires_at, v2.created_at, v2.commitment_txid, v2.spent_by, v2.spent, v2.unrolled, v2.preconfirmed, v2.settled_by, v2.ark_txid, v2.intent_id, v2.updated_at, v2.depth, v2.markers FROM vtxo v2 JOIN intent i2 ON i2.id = v2.intent_id WHERE i2.round_id = r.id + SELECT DISTINCT v2.txid, v2.vout, v2.pubkey, v2.amount, v2.expires_at, v2.created_at, v2.commitment_txid, v2.spent_by, v2.spent, v2.unrolled, v2.preconfirmed, v2.settled_by, v2.ark_txid, v2.intent_id, v2.updated_at, v2.depth, v2.markers, v2.vtxo_kind FROM vtxo v2 JOIN intent i2 ON i2.id = v2.intent_id WHERE i2.round_id = r.id ) as intent_with_inputs_amount ) AS total_forfeit_amount, ( @@ -1429,7 +1434,7 @@ func (q *Queries) SelectRoundVtxoTree(ctx context.Context, txid string) ([]Tx, e } const selectRoundVtxoTreeLeaves = `-- name: SelectRoundVtxoTreeLeaves :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE commitment_txid = ?1 AND preconfirmed = false +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE commitment_txid = ?1 AND preconfirmed = false ` type SelectRoundVtxoTreeLeavesRow struct { @@ -1463,6 +1468,7 @@ func (q *Queries) SelectRoundVtxoTreeLeaves(ctx context.Context, commitmentTxid &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -1728,7 +1734,7 @@ func (q *Queries) SelectSweepableRounds(ctx context.Context) ([]string, error) { } const selectSweepableUnrolledVtxos = `-- name: SelectSweepableUnrolledVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE spent = true AND unrolled = true AND swept = false AND (COALESCE(settled_by, '') = '') +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE spent = true AND unrolled = true AND swept = false AND (COALESCE(settled_by, '') = '') ` type SelectSweepableUnrolledVtxosRow struct { @@ -1762,6 +1768,7 @@ func (q *Queries) SelectSweepableUnrolledVtxos(ctx context.Context) ([]SelectSwe &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -1950,7 +1957,7 @@ func (q *Queries) SelectTxs(ctx context.Context, arg SelectTxsParams) ([]SelectT } const selectVtxo = `-- name: SelectVtxo :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE txid = ?1 AND vout = ?2 +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE txid = ?1 AND vout = ?2 ` type SelectVtxoParams struct { @@ -1989,6 +1996,7 @@ func (q *Queries) SelectVtxo(ctx context.Context, arg SelectVtxoParams) ([]Selec &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2008,7 +2016,7 @@ func (q *Queries) SelectVtxo(ctx context.Context, arg SelectVtxoParams) ([]Selec } const selectVtxoChainByMarker = `-- name: SelectVtxoChainByMarker :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE markers LIKE '%"' || ?1 || '"%' ORDER BY vtxo_vw.depth DESC ` @@ -2047,6 +2055,7 @@ func (q *Queries) SelectVtxoChainByMarker(ctx context.Context, markerID sql.Null &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2066,7 +2075,7 @@ func (q *Queries) SelectVtxoChainByMarker(ctx context.Context, markerID sql.Null } const selectVtxoInputsByRoundId = `-- name: SelectVtxoInputsByRoundId :many -SELECT intent_with_inputs_vw.txid, intent_with_inputs_vw.vout, intent_with_inputs_vw.pubkey, intent_with_inputs_vw.amount, intent_with_inputs_vw.expires_at, intent_with_inputs_vw.created_at, intent_with_inputs_vw.commitment_txid, intent_with_inputs_vw.spent_by, intent_with_inputs_vw.spent, intent_with_inputs_vw.unrolled, intent_with_inputs_vw.preconfirmed, intent_with_inputs_vw.settled_by, intent_with_inputs_vw.ark_txid, intent_with_inputs_vw.intent_id, intent_with_inputs_vw.updated_at, intent_with_inputs_vw.depth, intent_with_inputs_vw.markers, intent_with_inputs_vw.commitments, intent_with_inputs_vw.swept, intent_with_inputs_vw.asset_id, intent_with_inputs_vw.asset_amount, intent_with_inputs_vw.id, intent_with_inputs_vw.round_id, intent_with_inputs_vw.proof, intent_with_inputs_vw.message, intent_with_inputs_vw.intent_txid +SELECT intent_with_inputs_vw.txid, intent_with_inputs_vw.vout, intent_with_inputs_vw.pubkey, intent_with_inputs_vw.amount, intent_with_inputs_vw.expires_at, intent_with_inputs_vw.created_at, intent_with_inputs_vw.commitment_txid, intent_with_inputs_vw.spent_by, intent_with_inputs_vw.spent, intent_with_inputs_vw.unrolled, intent_with_inputs_vw.preconfirmed, intent_with_inputs_vw.settled_by, intent_with_inputs_vw.ark_txid, intent_with_inputs_vw.intent_id, intent_with_inputs_vw.updated_at, intent_with_inputs_vw.depth, intent_with_inputs_vw.markers, intent_with_inputs_vw.vtxo_kind, intent_with_inputs_vw.commitments, intent_with_inputs_vw.swept, intent_with_inputs_vw.asset_id, intent_with_inputs_vw.asset_amount, intent_with_inputs_vw.id, intent_with_inputs_vw.round_id, intent_with_inputs_vw.proof, intent_with_inputs_vw.message, intent_with_inputs_vw.intent_txid FROM intent_with_inputs_vw WHERE intent_with_inputs_vw.round_id = ?1 ` @@ -2102,6 +2111,7 @@ func (q *Queries) SelectVtxoInputsByRoundId(ctx context.Context, roundID sql.Nul &i.IntentWithInputsVw.UpdatedAt, &i.IntentWithInputsVw.Depth, &i.IntentWithInputsVw.Markers, + &i.IntentWithInputsVw.VtxoKind, &i.IntentWithInputsVw.Commitments, &i.IntentWithInputsVw.Swept, &i.IntentWithInputsVw.AssetID, @@ -2234,7 +2244,7 @@ func (q *Queries) SelectVtxoPubKeysByCommitmentTxids(ctx context.Context, arg Se } const selectVtxosByArkTxid = `-- name: SelectVtxosByArkTxid :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE ark_txid = ?1 +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE ark_txid = ?1 ` type SelectVtxosByArkTxidRow struct { @@ -2269,6 +2279,7 @@ func (q *Queries) SelectVtxosByArkTxid(ctx context.Context, arkTxid sql.NullStri &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2289,7 +2300,7 @@ func (q *Queries) SelectVtxosByArkTxid(ctx context.Context, arkTxid sql.NullStri const selectVtxosByDepthRange = `-- name: SelectVtxosByDepthRange :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE depth >= ?1 AND depth <= ?2 ORDER BY depth DESC ` @@ -2332,6 +2343,7 @@ func (q *Queries) SelectVtxosByDepthRange(ctx context.Context, arg SelectVtxosBy &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2351,7 +2363,7 @@ func (q *Queries) SelectVtxosByDepthRange(ctx context.Context, arg SelectVtxosBy } const selectVtxosByMarkerId = `-- name: SelectVtxosByMarkerId :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE markers LIKE '%"' || ?1 || '"%' +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE markers LIKE '%"' || ?1 || '"%' ` type SelectVtxosByMarkerIdRow struct { @@ -2388,6 +2400,7 @@ func (q *Queries) SelectVtxosByMarkerId(ctx context.Context, markerID sql.NullSt &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -2476,7 +2489,7 @@ func (q *Queries) SelectVtxosOutpointsByArkTxidRecursive(ctx context.Context, ar } const selectVtxosWithPubkeys = `-- name: SelectVtxosWithPubkeys :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE updated_at >= ?1 +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE updated_at >= ?1 AND (CAST(?2 AS INTEGER) = 0 OR updated_at <= CAST(?2 AS INTEGER)) AND pubkey IN (/*SLICE:pubkeys*/?) ` @@ -2530,6 +2543,7 @@ func (q *Queries) SelectVtxosWithPubkeys(ctx context.Context, arg SelectVtxosWit &i.VtxoVw.UpdatedAt, &i.VtxoVw.Depth, &i.VtxoVw.Markers, + &i.VtxoVw.VtxoKind, &i.VtxoVw.Commitments, &i.VtxoVw.Swept, &i.VtxoVw.AssetID, @@ -3105,11 +3119,11 @@ func (q *Queries) UpsertTx(ctx context.Context, arg UpsertTxParams) error { const upsertVtxo = `-- name: UpsertVtxo :exec INSERT INTO vtxo ( txid, vout, pubkey, amount, commitment_txid, settled_by, ark_txid, - spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers + spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers, vtxo_kind ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, - ?8, ?9, ?10, ?11, ?12, ?13, (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)), ?14, ?15 + ?8, ?9, ?10, ?11, ?12, ?13, (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)), ?14, ?15, ?16 ) ON CONFLICT(txid, vout) DO UPDATE SET pubkey = EXCLUDED.pubkey, amount = EXCLUDED.amount, @@ -3124,7 +3138,8 @@ VALUES ( created_at = EXCLUDED.created_at, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)), depth = EXCLUDED.depth, - markers = EXCLUDED.markers + markers = EXCLUDED.markers, + vtxo_kind = EXCLUDED.vtxo_kind ` type UpsertVtxoParams struct { @@ -3143,6 +3158,7 @@ type UpsertVtxoParams struct { CreatedAt int64 Depth int64 Markers string + VtxoKind int64 } func (q *Queries) UpsertVtxo(ctx context.Context, arg UpsertVtxoParams) error { @@ -3162,6 +3178,7 @@ func (q *Queries) UpsertVtxo(ctx context.Context, arg UpsertVtxoParams) error { arg.CreatedAt, arg.Depth, arg.Markers, + arg.VtxoKind, ) return err } diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index a1b80b8c4..12220b0ad 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -49,11 +49,11 @@ ON CONFLICT(intent_id, pubkey, onchain_address) DO UPDATE SET -- name: UpsertVtxo :exec INSERT INTO vtxo ( txid, vout, pubkey, amount, commitment_txid, settled_by, ark_txid, - spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers + spent_by, spent, unrolled, preconfirmed, expires_at, created_at, updated_at, depth, markers, vtxo_kind ) VALUES ( @txid, @vout, @pubkey, @amount, @commitment_txid, @settled_by, @ark_txid, - @spent_by, @spent, @unrolled, @preconfirmed, @expires_at, @created_at, (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)), @depth, @markers + @spent_by, @spent, @unrolled, @preconfirmed, @expires_at, @created_at, (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)), @depth, @markers, @vtxo_kind ) ON CONFLICT(txid, vout) DO UPDATE SET pubkey = EXCLUDED.pubkey, amount = EXCLUDED.amount, @@ -68,7 +68,8 @@ VALUES ( created_at = EXCLUDED.created_at, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)), depth = EXCLUDED.depth, - markers = EXCLUDED.markers; + markers = EXCLUDED.markers, + vtxo_kind = EXCLUDED.vtxo_kind; -- name: InsertVtxoCommitmentTxid :exec INSERT INTO vtxo_commitment_txid (vtxo_txid, vtxo_vout, commitment_txid) diff --git a/internal/infrastructure/db/sqlite/vtxo_repo.go b/internal/infrastructure/db/sqlite/vtxo_repo.go index 8b9ae98ab..3a4e6ac73 100644 --- a/internal/infrastructure/db/sqlite/vtxo_repo.go +++ b/internal/infrastructure/db/sqlite/vtxo_repo.go @@ -76,8 +76,9 @@ func (v *vtxoRepository) AddVtxos(ctx context.Context, vtxos []domain.Vtxo) erro String: vtxo.SettledBy, Valid: len(vtxo.SettledBy) > 0, }, - Depth: int64(vtxo.Depth), - Markers: markersJSON, + Depth: int64(vtxo.Depth), + Markers: markersJSON, + VtxoKind: int64(vtxo.Kind), }, ); err != nil { return err @@ -736,6 +737,7 @@ func rowToVtxo(row queries.VtxoVw) domain.Vtxo { Depth: uint32(row.Depth), MarkerIDs: parseMarkersJSONFromVtxo(row.Markers), Assets: assets, + Kind: domain.VtxoKind(row.VtxoKind), } } diff --git a/internal/infrastructure/db/vtxo_kind_down_test.go b/internal/infrastructure/db/vtxo_kind_down_test.go new file mode 100644 index 000000000..0b73990e4 --- /dev/null +++ b/internal/infrastructure/db/vtxo_kind_down_test.go @@ -0,0 +1,82 @@ +package db_test + +import ( + "database/sql" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +const addVtxoKindMigrationVersion = 20260724000000 + +// TestAddVtxoKindDownMigration verifies the add_vtxo_kind migration is +// reversible: up adds the vtxo_kind column and surfaces it through vtxo_vw, +// down drops the column and recreates the views without it, and a re-apply +// works cleanly. +func TestAddVtxoKindDownMigration(t *testing.T) { + m, db := newSweptVtxoMigrator(t) + t.Cleanup(func() { + //nolint:errcheck + db.Close() + }) + + require.NoError(t, m.Migrate(addVtxoKindMigrationVersion)) + + // Up: vtxo_kind exists on the base table and is visible through vtxo_vw. + require.True(t, hasColumn(t, db, "vtxo", "vtxo_kind"), + "vtxo.vtxo_kind should exist after the up migration") + require.True(t, hasColumn(t, db, "vtxo_vw", "vtxo_kind"), + "vtxo_vw should expose vtxo_kind after the up migration") + + // Down one step reverses add_vtxo_kind. + require.NoError(t, m.Steps(-1), "down migration must succeed") + require.False(t, hasColumn(t, db, "vtxo", "vtxo_kind"), + "vtxo.vtxo_kind should be gone after the down migration") + require.False(t, hasColumn(t, db, "vtxo_vw", "vtxo_kind"), + "vtxo_vw should not expose vtxo_kind after the down migration") + require.True(t, viewExists(t, db, "vtxo_vw"), + "vtxo_vw should be recreated by the down migration") + + // Re-applying forward must succeed. + require.NoError(t, m.Steps(1), "re-applying the up migration must succeed") + require.True(t, hasColumn(t, db, "vtxo", "vtxo_kind"), + "vtxo.vtxo_kind should exist again after re-applying") +} + +// hasColumn reports whether the given table or view exposes a column, via +// sqlite's PRAGMA table_info (which works for views too). +func hasColumn(t *testing.T, db *sql.DB, table, column string) bool { + t.Helper() + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + require.NoError(t, err) + defer rows.Close() + for rows.Next() { + var ( + cid int + name, ctype string + notnull, pk int + dflt sql.NullString + ) + require.NoError(t, rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk)) + if name == column { + return true + } + } + require.NoError(t, rows.Err()) + return false +} + +// viewExists reports whether a view of the given name exists. +func viewExists(t *testing.T, db *sql.DB, name string) bool { + t.Helper() + var got string + err := db.QueryRow( + "SELECT name FROM sqlite_master WHERE type='view' AND name=?", name, + ).Scan(&got) + if err == sql.ErrNoRows { + return false + } + require.NoError(t, err) + return got == name +} From f7cfcc1a862762a78666cd2549101ab4bd78dfd4 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:10:45 -0400 Subject: [PATCH 2/7] db: carry VtxoKind through the round and marker row converters 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. --- internal/infrastructure/db/postgres/marker_repo.go | 2 ++ internal/infrastructure/db/postgres/round_repo.go | 1 + internal/infrastructure/db/sqlite/marker_repo.go | 4 ++++ internal/infrastructure/db/sqlite/round_repo.go | 1 + 4 files changed, 8 insertions(+) diff --git a/internal/infrastructure/db/postgres/marker_repo.go b/internal/infrastructure/db/postgres/marker_repo.go index f528cb787..6aa60bf43 100644 --- a/internal/infrastructure/db/postgres/marker_repo.go +++ b/internal/infrastructure/db/postgres/marker_repo.go @@ -331,6 +331,7 @@ func rowToVtxoFromVtxoVw(row queries.VtxoVw) domain.Vtxo { CreatedAt: row.CreatedAt, Depth: uint32(row.Depth), MarkerIDs: parseMarkersJSONB(row.Markers), + Kind: domain.VtxoKind(row.VtxoKind), } } @@ -369,6 +370,7 @@ func rowToVtxoFromMarkerQuery(row queries.SelectVtxosByMarkerIdRow) domain.Vtxo ExpiresAt: row.VtxoVw.ExpiresAt, CreatedAt: row.VtxoVw.CreatedAt, Depth: uint32(row.VtxoVw.Depth), + Kind: domain.VtxoKind(row.VtxoVw.VtxoKind), MarkerIDs: parseMarkersJSONB(row.VtxoVw.Markers), } } diff --git a/internal/infrastructure/db/postgres/round_repo.go b/internal/infrastructure/db/postgres/round_repo.go index b66279a40..d6f56a247 100644 --- a/internal/infrastructure/db/postgres/round_repo.go +++ b/internal/infrastructure/db/postgres/round_repo.go @@ -699,6 +699,7 @@ func combinedRowToVtxo(row queries.IntentWithInputsVw) domain.Vtxo { CreatedAt: row.CreatedAt.Int64, ArkTxid: row.ArkTxid.String, SettledBy: row.SettledBy.String, + Kind: domain.VtxoKind(row.VtxoKind.Int32), } } diff --git a/internal/infrastructure/db/sqlite/marker_repo.go b/internal/infrastructure/db/sqlite/marker_repo.go index 2243213e5..ef71fe7a7 100644 --- a/internal/infrastructure/db/sqlite/marker_repo.go +++ b/internal/infrastructure/db/sqlite/marker_repo.go @@ -399,6 +399,7 @@ func rowToVtxoFromMarkerQuery(row queries.SelectVtxosByMarkerIdRow) domain.Vtxo ExpiresAt: row.VtxoVw.ExpiresAt, CreatedAt: row.VtxoVw.CreatedAt, Depth: uint32(row.VtxoVw.Depth), + Kind: domain.VtxoKind(row.VtxoVw.VtxoKind), MarkerIDs: parseMarkersJSON(row.VtxoVw.Markers), } } @@ -427,6 +428,7 @@ func rowToVtxoFromDepthRangeQuery(row queries.SelectVtxosByDepthRangeRow) domain ExpiresAt: row.VtxoVw.ExpiresAt, CreatedAt: row.VtxoVw.CreatedAt, Depth: uint32(row.VtxoVw.Depth), + Kind: domain.VtxoKind(row.VtxoVw.VtxoKind), MarkerIDs: parseMarkersJSON(row.VtxoVw.Markers), } } @@ -455,6 +457,7 @@ func rowToVtxoFromArkTxidQuery(row queries.SelectVtxosByArkTxidRow) domain.Vtxo ExpiresAt: row.VtxoVw.ExpiresAt, CreatedAt: row.VtxoVw.CreatedAt, Depth: uint32(row.VtxoVw.Depth), + Kind: domain.VtxoKind(row.VtxoVw.VtxoKind), MarkerIDs: parseMarkersJSON(row.VtxoVw.Markers), } } @@ -483,6 +486,7 @@ func rowToVtxoFromChainQuery(row queries.SelectVtxoChainByMarkerRow) domain.Vtxo ExpiresAt: row.VtxoVw.ExpiresAt, CreatedAt: row.VtxoVw.CreatedAt, Depth: uint32(row.VtxoVw.Depth), + Kind: domain.VtxoKind(row.VtxoVw.VtxoKind), MarkerIDs: parseMarkersJSON(row.VtxoVw.Markers), } } diff --git a/internal/infrastructure/db/sqlite/round_repo.go b/internal/infrastructure/db/sqlite/round_repo.go index 73101f053..4dfc64434 100644 --- a/internal/infrastructure/db/sqlite/round_repo.go +++ b/internal/infrastructure/db/sqlite/round_repo.go @@ -832,6 +832,7 @@ func combinedRowToVtxo(row queries.IntentWithInputsVw) domain.Vtxo { CreatedAt: row.CreatedAt.Int64, ArkTxid: row.ArkTxid.String, SettledBy: row.SettledBy.String, + Kind: domain.VtxoKind(row.VtxoKind.Int64), } } From fc48791aadc54daa06bab068f1eb7b0f731db1ec Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:34:30 -0400 Subject: [PATCH 3/7] domain: an on-chain Arkade vtxo never expires 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. --- internal/core/domain/vtxo.go | 7 +++++++ internal/core/domain/vtxo_test.go | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/internal/core/domain/vtxo.go b/internal/core/domain/vtxo.go index 33d8bd87b..e39707ce5 100644 --- a/internal/core/domain/vtxo.go +++ b/internal/core/domain/vtxo.go @@ -117,5 +117,12 @@ func (v Vtxo) OutputScript() ([]byte, error) { } func (v Vtxo) IsExpired() bool { + // An on-chain Arkade UTXO has no batch expiry, so ExpiresAt is not + // meaningful for it. Without this an on-chain vtxo (which carries a zero + // ExpiresAt) would read as permanently expired and be treated as + // unspendable by every caller. + if v.Kind == VtxoKindOnchain { + return false + } return time.Now().After(time.Unix(v.ExpiresAt, 0)) } diff --git a/internal/core/domain/vtxo_test.go b/internal/core/domain/vtxo_test.go index 4c7580644..f89521c27 100644 --- a/internal/core/domain/vtxo_test.go +++ b/internal/core/domain/vtxo_test.go @@ -123,6 +123,16 @@ func TestVtxo_IsExpired(t *testing.T) { vtxo: domain.Vtxo{ExpiresAt: time.Now().Add(time.Hour).Unix()}, isExpired: false, }, + { + // An on-chain Arkade UTXO has no batch expiry, so a zero ExpiresAt + // must not read as expired. + name: "onchain kind never expires", + vtxo: domain.Vtxo{ + Kind: domain.VtxoKindOnchain, + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + }, + isExpired: false, + }, } for _, f := range fixtures { t.Run(f.name, func(t *testing.T) { From 2d996015aca979f30ae4dc55eee718e32cc85aab Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:37:31 -0400 Subject: [PATCH 4/7] db: renumber add_vtxo_kind above the migrations master added since --- ...vtxo_kind.down.sql => 20260901000000_add_vtxo_kind.down.sql} | 0 ...add_vtxo_kind.up.sql => 20260901000000_add_vtxo_kind.up.sql} | 0 ...vtxo_kind.down.sql => 20260901000000_add_vtxo_kind.down.sql} | 0 ...add_vtxo_kind.up.sql => 20260901000000_add_vtxo_kind.up.sql} | 0 internal/infrastructure/db/vtxo_kind_down_test.go | 2 +- 5 files changed, 1 insertion(+), 1 deletion(-) rename internal/infrastructure/db/postgres/migration/{20260724000000_add_vtxo_kind.down.sql => 20260901000000_add_vtxo_kind.down.sql} (100%) rename internal/infrastructure/db/postgres/migration/{20260724000000_add_vtxo_kind.up.sql => 20260901000000_add_vtxo_kind.up.sql} (100%) rename internal/infrastructure/db/sqlite/migration/{20260724000000_add_vtxo_kind.down.sql => 20260901000000_add_vtxo_kind.down.sql} (100%) rename internal/infrastructure/db/sqlite/migration/{20260724000000_add_vtxo_kind.up.sql => 20260901000000_add_vtxo_kind.up.sql} (100%) diff --git a/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.down.sql b/internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.down.sql similarity index 100% rename from internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.down.sql rename to internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.down.sql diff --git a/internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.up.sql b/internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.up.sql similarity index 100% rename from internal/infrastructure/db/postgres/migration/20260724000000_add_vtxo_kind.up.sql rename to internal/infrastructure/db/postgres/migration/20260901000000_add_vtxo_kind.up.sql diff --git a/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.down.sql b/internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.down.sql similarity index 100% rename from internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.down.sql rename to internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.down.sql diff --git a/internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.up.sql b/internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.up.sql similarity index 100% rename from internal/infrastructure/db/sqlite/migration/20260724000000_add_vtxo_kind.up.sql rename to internal/infrastructure/db/sqlite/migration/20260901000000_add_vtxo_kind.up.sql diff --git a/internal/infrastructure/db/vtxo_kind_down_test.go b/internal/infrastructure/db/vtxo_kind_down_test.go index 0b73990e4..db5407c81 100644 --- a/internal/infrastructure/db/vtxo_kind_down_test.go +++ b/internal/infrastructure/db/vtxo_kind_down_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -const addVtxoKindMigrationVersion = 20260724000000 +const addVtxoKindMigrationVersion = 20260901000000 // TestAddVtxoKindDownMigration verifies the add_vtxo_kind migration is // reversible: up adds the vtxo_kind column and surfaces it through vtxo_vw, From 4b3a433f6ac1fffb3c92808106bbf449e698325f Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:48:37 -0400 Subject: [PATCH 5/7] domain: on-chain kind never requires a forfeit 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. --- internal/core/domain/vtxo.go | 4 +++- internal/core/domain/vtxo_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/core/domain/vtxo.go b/internal/core/domain/vtxo.go index e67bdd499..a0578fa32 100644 --- a/internal/core/domain/vtxo.go +++ b/internal/core/domain/vtxo.go @@ -93,7 +93,9 @@ func (v Vtxo) IsNote() bool { } func (v Vtxo) RequiresForfeit() bool { - return !v.Swept && !v.IsNote() && !v.Unrolled + // An on-chain Arkade UTXO joins a batch as a boarding input, which is + // signed directly and never forfeited. + return v.Kind != VtxoKindOnchain && !v.Swept && !v.IsNote() && !v.Unrolled } func (v Vtxo) IsSettled() bool { diff --git a/internal/core/domain/vtxo_test.go b/internal/core/domain/vtxo_test.go index 9086a0b87..5502a8b2e 100644 --- a/internal/core/domain/vtxo_test.go +++ b/internal/core/domain/vtxo_test.go @@ -191,6 +191,18 @@ func TestVtxo_RequiresForfeit(t *testing.T) { }, requiresForfeit: false, }, + { + // An on-chain Arkade UTXO is a boarding input in a batch, never a + // forfeited vtxo, even when it carries commitment txids and so + // would not read as a note. + name: "should be false (onchain kind)", + vtxo: domain.Vtxo{ + CommitmentTxids: []string{"txid1"}, + ExpiresAt: futureExpiry, + Kind: domain.VtxoKindOnchain, + }, + requiresForfeit: false, + }, } for _, f := range fixtures { t.Run(f.name, func(t *testing.T) { From 262e626d2d24614d975923d4dbac2edee2233748 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:48:45 -0400 Subject: [PATCH 6/7] db: run the add_vtxo_kind down-migration test on postgres too Same up, down, re-apply sequence on both SQL backends, against a database the test owns on the shared test server. --- .../infrastructure/db/vtxo_kind_down_test.go | 122 ++++++++++++++++-- 1 file changed, 108 insertions(+), 14 deletions(-) diff --git a/internal/infrastructure/db/vtxo_kind_down_test.go b/internal/infrastructure/db/vtxo_kind_down_test.go index db5407c81..1db70d9bf 100644 --- a/internal/infrastructure/db/vtxo_kind_down_test.go +++ b/internal/infrastructure/db/vtxo_kind_down_test.go @@ -2,48 +2,142 @@ package db_test import ( "database/sql" + "embed" "fmt" "testing" + pgdb "github.com/arkade-os/arkd/internal/infrastructure/db/postgres" + "github.com/golang-migrate/migrate/v4" + migratepg "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/stretchr/testify/require" ) -const addVtxoKindMigrationVersion = 20260901000000 +const ( + addVtxoKindMigrationVersion = 20260901000000 + // addVtxoKindPostgresDsn names a database this test owns on the same test + // server TestService uses, so the two never share state. + addVtxoKindPostgresDsn = "postgresql://root:secret@127.0.0.1:5432/vtxo_kind_migration?sslmode=disable" +) // TestAddVtxoKindDownMigration verifies the add_vtxo_kind migration is -// reversible: up adds the vtxo_kind column and surfaces it through vtxo_vw, -// down drops the column and recreates the views without it, and a re-apply -// works cleanly. +// reversible on both SQL backends: up adds the vtxo_kind column and surfaces +// it through vtxo_vw, down drops the column and recreates the views without +// it, and a re-apply works cleanly. func TestAddVtxoKindDownMigration(t *testing.T) { - m, db := newSweptVtxoMigrator(t) - t.Cleanup(func() { - //nolint:errcheck - db.Close() + t.Run("sqlite", func(t *testing.T) { + m, db := newSweptVtxoMigrator(t) + t.Cleanup(func() { + //nolint:errcheck + db.Close() + }) + testAddVtxoKindDownMigration(t, m, sqliteSchema{db}) + }) + + t.Run("postgres", func(t *testing.T) { + m, db := newVtxoKindPostgresMigrator(t) + t.Cleanup(func() { + //nolint:errcheck + db.Close() + }) + testAddVtxoKindDownMigration(t, m, postgresSchema{db}) }) +} +func testAddVtxoKindDownMigration(t *testing.T, m *migrate.Migrate, s schema) { require.NoError(t, m.Migrate(addVtxoKindMigrationVersion)) // Up: vtxo_kind exists on the base table and is visible through vtxo_vw. - require.True(t, hasColumn(t, db, "vtxo", "vtxo_kind"), + require.True(t, s.hasColumn(t, "vtxo", "vtxo_kind"), "vtxo.vtxo_kind should exist after the up migration") - require.True(t, hasColumn(t, db, "vtxo_vw", "vtxo_kind"), + require.True(t, s.hasColumn(t, "vtxo_vw", "vtxo_kind"), "vtxo_vw should expose vtxo_kind after the up migration") // Down one step reverses add_vtxo_kind. require.NoError(t, m.Steps(-1), "down migration must succeed") - require.False(t, hasColumn(t, db, "vtxo", "vtxo_kind"), + require.False(t, s.hasColumn(t, "vtxo", "vtxo_kind"), "vtxo.vtxo_kind should be gone after the down migration") - require.False(t, hasColumn(t, db, "vtxo_vw", "vtxo_kind"), + require.False(t, s.hasColumn(t, "vtxo_vw", "vtxo_kind"), "vtxo_vw should not expose vtxo_kind after the down migration") - require.True(t, viewExists(t, db, "vtxo_vw"), + require.True(t, s.viewExists(t, "vtxo_vw"), "vtxo_vw should be recreated by the down migration") // Re-applying forward must succeed. require.NoError(t, m.Steps(1), "re-applying the up migration must succeed") - require.True(t, hasColumn(t, db, "vtxo", "vtxo_kind"), + require.True(t, s.hasColumn(t, "vtxo", "vtxo_kind"), "vtxo.vtxo_kind should exist again after re-applying") } +// --- helpers --- + +//go:embed postgres/migration/* +var vtxoKindPostgresMigrations embed.FS + +// schema inspects a backend's catalog for the columns and views the test +// asserts on. +type schema interface { + hasColumn(t *testing.T, table, column string) bool + viewExists(t *testing.T, name string) bool +} + +// newVtxoKindPostgresMigrator opens the test's own postgres database, creating +// it if needed, wipes whatever a previous run left in it, and returns a +// migrate.Migrate bound to the embedded postgres migration source. +func newVtxoKindPostgresMigrator(t *testing.T) (*migrate.Migrate, *sql.DB) { + t.Helper() + db, err := pgdb.OpenDb(addVtxoKindPostgresDsn, true) + require.NoError(t, err) + + newMigrator := func() *migrate.Migrate { + driver, err := migratepg.WithInstance(db, &migratepg.Config{}) + require.NoError(t, err) + source, err := iofs.New(vtxoKindPostgresMigrations, "postgres/migration") + require.NoError(t, err) + m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) + require.NoError(t, err) + return m + } + + // Drop invalidates the instance it ran on, so build a fresh one after it. + require.NoError(t, newMigrator().Drop()) + return newMigrator(), db +} + +type sqliteSchema struct{ db *sql.DB } + +func (s sqliteSchema) hasColumn(t *testing.T, table, column string) bool { + return hasColumn(t, s.db, table, column) +} + +func (s sqliteSchema) viewExists(t *testing.T, name string) bool { + return viewExists(t, s.db, name) +} + +type postgresSchema struct{ db *sql.DB } + +// hasColumn reads information_schema.columns, which lists view columns as +// well as table columns. +func (s postgresSchema) hasColumn(t *testing.T, table, column string) bool { + t.Helper() + var n int + require.NoError(t, s.db.QueryRow( + `SELECT count(*) FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2`, + table, column, + ).Scan(&n)) + return n > 0 +} + +func (s postgresSchema) viewExists(t *testing.T, name string) bool { + t.Helper() + var n int + require.NoError(t, s.db.QueryRow( + `SELECT count(*) FROM information_schema.views + WHERE table_schema = current_schema() AND table_name = $1`, name, + ).Scan(&n)) + return n > 0 +} + // hasColumn reports whether the given table or view exposes a column, via // sqlite's PRAGMA table_info (which works for views too). func hasColumn(t *testing.T, db *sql.DB, table, column string) bool { From 7c9e8e87844cef4983bc6012578aa1088ec8c594 Mon Sep 17 00:00:00 2001 From: Bob Smith <5396652+bitcoin-coder-bob@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:32:14 -0400 Subject: [PATCH 7/7] vtxo: widen onchain spend tracking to onchain-kind vtxos Closes #1181. Part of #1159, follow-up to #1174 on top of #1161. #1174 tracks the onchain spends of unrolled vtxos, the only vtxos with an onchain output until now. With #1161 arkd also records vtxos held in on-chain Arkade UTXOs, Kind = Onchain, whose unilateral spends bypass arkd exactly as an unrolled vtxo's do. Every predicate the tracking keyed on unrolled now takes either shape: - Vtxo.HasOnchainOutput is the one Go predicate, unrolled or onchain kind, and IsOnchainSpent builds on it. - The five sql statements, mark, re-point, retract and the two candidate selectors, match (unrolled = true OR vtxo_kind = 1) on sqlite and postgres. - The badger selectors take both shapes and its mark guard uses the predicate. - applyOnchainSpends and the restore loop in restoreWatchingVtxos follow, the latter through the widened selectors. SelectSweepableUnrolledVtxos stays as it is: an onchain-kind UTXO has no checkpoint tx for the sweeper to resolve, and its ark_txid predicate already keeps onchain spends out. Tests, on all three backends with Kind = Onchain rows inserted directly so nothing waits for the write path: mark, re-point and retract work on an onchain-kind row, the selectors partition by kind as they do by unrolled, and an onchain-kind row never enters the sweepable set. The IsOnchainSpent table and the applyOnchainSpends unit test gain the kind cases. Removing the kind from the predicate fails the domain and application cases; the sql cases cannot pass on the old statements, which never matched a row that was not unrolled. --- internal/core/application/onchain_spend.go | 9 ++- .../core/application/onchain_spend_test.go | 18 +++++ internal/core/application/service.go | 9 ++- internal/core/domain/vtxo.go | 19 +++-- internal/core/domain/vtxo_repo.go | 10 ++- internal/core/domain/vtxo_test.go | 17 ++++ .../infrastructure/db/badger/vtxo_repo.go | 22 +++++- .../db/onchain_spend_repo_test.go | 79 +++++++++++++++++++ .../db/postgres/sqlc/queries/query.sql.go | 13 +-- .../infrastructure/db/postgres/sqlc/query.sql | 13 +-- .../db/sqlite/sqlc/queries/query.sql.go | 13 +-- .../infrastructure/db/sqlite/sqlc/query.sql | 13 +-- 12 files changed, 194 insertions(+), 41 deletions(-) diff --git a/internal/core/application/onchain_spend.go b/internal/core/application/onchain_spend.go index 96c12535f..855833fbc 100644 --- a/internal/core/application/onchain_spend.go +++ b/internal/core/application/onchain_spend.go @@ -67,10 +67,11 @@ func (s *service) applyOnchainSpends(ctx context.Context, spends []ports.Spend) spentBy := make(map[domain.Outpoint]string) for _, vtxo := range vtxos { - // Only an unrolled vtxo has an onchain output to spend. A vtxo already - // spent inside the Ark is left alone: MarkVtxosOnchainSpent would ignore - // it anyway, and filtering here keeps the log honest. - if !vtxo.Unrolled { + // Only a vtxo with an onchain output, unrolled or onchain-kind, can be + // spent onchain. A vtxo already spent inside the Ark is left alone: + // MarkVtxosOnchainSpent would ignore it anyway, and filtering here keeps + // the log honest. + if !vtxo.HasOnchainOutput() { continue } if vtxo.Spent && !vtxo.IsOnchainSpent() { diff --git a/internal/core/application/onchain_spend_test.go b/internal/core/application/onchain_spend_test.go index b2609e57d..7d2fdcb3e 100644 --- a/internal/core/application/onchain_spend_test.go +++ b/internal/core/application/onchain_spend_test.go @@ -60,6 +60,24 @@ func TestOnchainSpends(t *testing.T) { ) }) + // An on-chain Arkade UTXO is never unrolled but has an onchain output all + // the same, so its spend is recorded like an unrolled vtxo's. + t.Run("records an onchain-kind vtxo spent onchain", func(t *testing.T) { + svc, vtxos := newService(t, []domain.Vtxo{ + {Outpoint: out, Kind: domain.VtxoKindOnchain}, + }) + vtxos.On("MarkVtxosOnchainSpent", mock.Anything, mock.Anything).Return(nil) + + require.NoError(t, svc.applyOnchainSpends( + context.Background(), []ports.Spend{spendOf(out, spendingTxid, 1)}, + )) + + vtxos.AssertCalled( + t, "MarkVtxosOnchainSpent", mock.Anything, + map[domain.Outpoint]string{out: spendingTxid}, + ) + }) + // The wallet watches boarding scripts as well as vtxo scripts, so most // notified spends refer to outputs that are not unrolled vtxos at all. t.Run("ignores a vtxo that was never unrolled", func(t *testing.T) { diff --git a/internal/core/application/service.go b/internal/core/application/service.go index e834edafb..ce62d2757 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -4143,9 +4143,10 @@ func (s *service) restoreWatchingVtxos() error { addKey(key) } - // Unrolled vtxos are watched independently of any round. Their batch may - // no longer be sweepable, so the loop above would not restore them, and an - // unwatched script is invisible to onchain spend tracking twice over: no + // Unrolled and onchain-kind vtxos are watched independently of any round: + // an unrolled vtxo's batch may no longer be sweepable, and an onchain-kind + // vtxo has no batch at all, so the loop above would not restore them, and + // an unwatched script is invisible to onchain spend tracking twice over: no // push notification arrives, and NBXplorer only records the matched inputs // the reconciler reads for sources it was tracking when it indexed the // spending transaction. Both directions are restored: still-unspent vtxos @@ -4158,7 +4159,7 @@ func (s *service) restoreWatchingVtxos() error { } { vtxos, err := load(ctx) if err != nil { - log.WithError(err).Warn("failed to fetch unrolled vtxos for restore") + log.WithError(err).Warn("failed to fetch onchain vtxos for restore") continue } for _, vtxo := range vtxos { diff --git a/internal/core/domain/vtxo.go b/internal/core/domain/vtxo.go index 215f85ac1..7d7cbb10e 100644 --- a/internal/core/domain/vtxo.go +++ b/internal/core/domain/vtxo.go @@ -102,13 +102,20 @@ func (v Vtxo) IsSettled() bool { return v.SettledBy != "" } -// IsOnchainSpent reports a vtxo that was unrolled and then spent onchain, -// outside the Ark. There is no dedicated column: an in-Ark spend always sets -// either ArkTxid (SpendVtxos, on an accepted offchain tx) or SettledBy -// (SettleVtxos, at batch settlement), so their absence on a spent and unrolled -// vtxo is what identifies the spend as onchain. +// HasOnchainOutput reports a vtxo with an output that can be spent onchain, +// outside the Ark: one that was unrolled, or one held in an on-chain Arkade +// UTXO. Those are the vtxos the onchain spend tracking watches. +func (v Vtxo) HasOnchainOutput() bool { + return v.Unrolled || v.Kind == VtxoKindOnchain +} + +// IsOnchainSpent reports a vtxo with an onchain output that was then spent +// onchain, outside the Ark. There is no dedicated column: an in-Ark spend +// always sets either ArkTxid (SpendVtxos, on an accepted offchain tx) or +// SettledBy (SettleVtxos, at batch settlement), so their absence on a spent +// vtxo with an onchain output is what identifies the spend as onchain. func (v Vtxo) IsOnchainSpent() bool { - return v.Unrolled && v.Spent && v.SettledBy == "" && v.ArkTxid == "" + return v.HasOnchainOutput() && v.Spent && v.SettledBy == "" && v.ArkTxid == "" } func (v Vtxo) TapKey() (*btcec.PublicKey, error) { diff --git a/internal/core/domain/vtxo_repo.go b/internal/core/domain/vtxo_repo.go index c800b58ce..25a5905f3 100644 --- a/internal/core/domain/vtxo_repo.go +++ b/internal/core/domain/vtxo_repo.go @@ -7,8 +7,9 @@ type VtxoRepository interface { SettleVtxos(ctx context.Context, spentVtxos map[Outpoint]string, commitmentTxid string) error SpendVtxos(ctx context.Context, spentVtxos map[Outpoint]string, arkTxid string) error UnrollVtxos(ctx context.Context, outpoints []Outpoint) error - // MarkVtxosOnchainSpent records unrolled vtxos spent onchain, outside the - // Ark, mapping each outpoint to the txid that spent it. It also re-points an + // MarkVtxosOnchainSpent records vtxos with an onchain output, unrolled or + // onchain-kind, spent onchain outside the Ark, mapping each outpoint to the + // txid that spent it. It also re-points an // already onchain-spent vtxo at a new spender, so an RBF replacement is // picked up. It never touches a vtxo spent offchain or settled in a batch. MarkVtxosOnchainSpent(ctx context.Context, spentBy map[Outpoint]string) error @@ -19,8 +20,9 @@ type VtxoRepository interface { GetVtxos(ctx context.Context, outpoints []Outpoint) ([]Vtxo, error) GetAllNonUnrolledVtxos(ctx context.Context, pubkey string) ([]Vtxo, []Vtxo, error) GetAllSweepableUnrolledVtxos(ctx context.Context) ([]Vtxo, error) - // GetUnrolledUnspentVtxos returns unrolled vtxos currently believed unspent: - // the candidate set the onchain-spend reconciler checks against the chain. + // GetUnrolledUnspentVtxos returns the vtxos with an onchain output, unrolled + // or onchain-kind, currently believed unspent: the candidate set the + // onchain-spend reconciler checks against the chain. GetUnrolledUnspentVtxos(ctx context.Context) ([]Vtxo, error) // GetOnchainSpentVtxos returns vtxos currently recorded as spent onchain, so // the reconciler can re-point or retract them. diff --git a/internal/core/domain/vtxo_test.go b/internal/core/domain/vtxo_test.go index a2da4134b..90f1b12b0 100644 --- a/internal/core/domain/vtxo_test.go +++ b/internal/core/domain/vtxo_test.go @@ -248,6 +248,23 @@ func TestVtxo_IsOnchainSpent(t *testing.T) { vtxo: domain.Vtxo{Unrolled: true, Spent: true, SettledBy: "commitmenttxid"}, expected: false, }, + { + name: "true (onchain kind, spent onchain)", + vtxo: domain.Vtxo{Kind: domain.VtxoKindOnchain, Spent: true}, + expected: true, + }, + { + name: "false (onchain kind, not spent)", + vtxo: domain.Vtxo{Kind: domain.VtxoKindOnchain}, + expected: false, + }, + { + name: "false (onchain kind, settled in a batch)", + vtxo: domain.Vtxo{ + Kind: domain.VtxoKindOnchain, Spent: true, SettledBy: "commitmenttxid", + }, + expected: false, + }, } for _, f := range fixtures { t.Run(f.name, func(t *testing.T) { diff --git a/internal/infrastructure/db/badger/vtxo_repo.go b/internal/infrastructure/db/badger/vtxo_repo.go index 67c51fb26..1e08d8d8c 100644 --- a/internal/infrastructure/db/badger/vtxo_repo.go +++ b/internal/infrastructure/db/badger/vtxo_repo.go @@ -266,12 +266,20 @@ func (r *VtxoRepository) GetAllSweepableUnrolledVtxos( func (r *VtxoRepository) GetUnrolledUnspentVtxos( ctx context.Context, ) ([]domain.Vtxo, error) { + // An onchain-kind vtxo has an onchain output without being unrolled, so + // both shapes are candidates. query := badgerhold.Where("Unrolled"). Eq(true). And("Spent"). Eq(false). And("Swept"). - Eq(false) + Eq(false). + Or(badgerhold.Where("Kind"). + Eq(domain.VtxoKindOnchain). + And("Spent"). + Eq(false). + And("Swept"). + Eq(false)) return r.findVtxos(ctx, query) } @@ -285,7 +293,15 @@ func (r *VtxoRepository) GetOnchainSpentVtxos( And("SettledBy"). Eq(""). And("ArkTxid"). - Eq("") + Eq(""). + Or(badgerhold.Where("Kind"). + Eq(domain.VtxoKindOnchain). + And("Spent"). + Eq(true). + And("SettledBy"). + Eq(""). + And("ArkTxid"). + Eq("")) return r.findVtxos(ctx, query) } @@ -821,7 +837,7 @@ func (r *VtxoRepository) markOnchainSpentVtxo( tx *badger.Txn, outpoint domain.Outpoint, spendingTxid string, ) error { vtxo, err := r.getVtxoTx(tx, outpoint) - if err != nil || vtxo == nil || !vtxo.Unrolled { + if err != nil || vtxo == nil || !vtxo.HasOnchainOutput() { return err } if vtxo.Spent && !vtxo.IsOnchainSpent() { diff --git a/internal/infrastructure/db/onchain_spend_repo_test.go b/internal/infrastructure/db/onchain_spend_repo_test.go index aaca97a7e..18abc73b6 100644 --- a/internal/infrastructure/db/onchain_spend_repo_test.go +++ b/internal/infrastructure/db/onchain_spend_repo_test.go @@ -95,6 +95,74 @@ func TestOnchainSpendRepository(t *testing.T) { require.Empty(t, got.SpentBy) }) + // An on-chain Arkade UTXO has an onchain output without ever being + // unrolled, so the same mark, re-point and retract apply to it. + t.Run("marks, re-points and retracts an onchain-kind vtxo", func(t *testing.T) { + vtxo := onchainKindVtxo(randomString(32)) + require.NoError(t, repo.AddVtxos(ctx, []domain.Vtxo{vtxo})) + + require.NoError(t, repo.MarkVtxosOnchainSpent( + ctx, map[domain.Outpoint]string{vtxo.Outpoint: "spendingtxid"}, + )) + got := getOnchainSpendVtxo(t, repo, vtxo.Outpoint) + require.True(t, got.Spent) + require.Equal(t, "spendingtxid", got.SpentBy) + require.True(t, got.IsOnchainSpent()) + require.Equal(t, domain.VtxoKindOnchain, got.Kind) + + require.NoError(t, repo.MarkVtxosOnchainSpent( + ctx, map[domain.Outpoint]string{vtxo.Outpoint: "replacementtxid"}, + )) + got = getOnchainSpendVtxo(t, repo, vtxo.Outpoint) + require.Equal(t, "replacementtxid", got.SpentBy) + + require.NoError(t, repo.UnmarkVtxosOnchainSpent( + ctx, []domain.Outpoint{vtxo.Outpoint}, + )) + got = getOnchainSpendVtxo(t, repo, vtxo.Outpoint) + require.False(t, got.Spent) + require.Empty(t, got.SpentBy) + require.False(t, got.Unrolled, "an onchain-kind vtxo is never unrolled") + require.Equal(t, domain.VtxoKindOnchain, got.Kind) + }) + + t.Run("selectors partition by kind as by unrolled", func(t *testing.T) { + unspent := onchainKindVtxo(randomString(32)) + spent := onchainKindVtxo(randomString(32)) + offchain := onchainSpendVtxo(randomString(32)) + require.NoError(t, repo.AddVtxos(ctx, []domain.Vtxo{unspent, spent, offchain})) + require.NoError(t, repo.MarkVtxosOnchainSpent( + ctx, map[domain.Outpoint]string{spent.Outpoint: "spendingtxid"}, + )) + + candidates, err := repo.GetUnrolledUnspentVtxos(ctx) + require.NoError(t, err) + require.True(t, containsOutpoint(candidates, unspent.Outpoint)) + require.False(t, containsOutpoint(candidates, spent.Outpoint)) + require.False(t, containsOutpoint(candidates, offchain.Outpoint), + "an offchain vtxo that was never unrolled has no onchain output") + + recorded, err := repo.GetOnchainSpentVtxos(ctx) + require.NoError(t, err) + require.True(t, containsOutpoint(recorded, spent.Outpoint)) + require.False(t, containsOutpoint(recorded, unspent.Outpoint)) + require.False(t, containsOutpoint(recorded, offchain.Outpoint)) + }) + + // The sweeper resolves SpentBy as a checkpoint tx. An onchain-kind + // UTXO has none, so its onchain spend must never reach the sweeper. + t.Run("an onchain-kind vtxo never enters the sweepable set", func(t *testing.T) { + vtxo := onchainKindVtxo(randomString(32)) + require.NoError(t, repo.AddVtxos(ctx, []domain.Vtxo{vtxo})) + require.NoError(t, repo.MarkVtxosOnchainSpent( + ctx, map[domain.Outpoint]string{vtxo.Outpoint: "spendingtxid"}, + )) + + sweepable, err := repo.GetAllSweepableUnrolledVtxos(ctx) + require.NoError(t, err) + require.False(t, containsOutpoint(sweepable, vtxo.Outpoint)) + }) + // The sweeper resolves SpentBy as a checkpoint tx, so an onchain // spend must stay out of its candidate set while an in-Ark spend that // was later unrolled must stay in it. @@ -300,6 +368,17 @@ func onchainSpendVtxo(txid string) domain.Vtxo { } } +// onchainKindVtxo is an on-chain Arkade UTXO as #1161 records it: onchain +// kind, no commitment and no batch expiry, never unrolled. +func onchainKindVtxo(txid string) domain.Vtxo { + vtxo := onchainSpendVtxo(txid) + vtxo.Kind = domain.VtxoKindOnchain + vtxo.CommitmentTxids = nil + vtxo.RootCommitmentTxid = "" + vtxo.ExpiresAt = 0 + return vtxo +} + func getOnchainSpendVtxo( t *testing.T, repo domain.VtxoRepository, outpoint domain.Outpoint, ) domain.Vtxo { diff --git a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go index 0c63b0e9e..2945ffb48 100644 --- a/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/postgres/sqlc/queries/query.sql.go @@ -995,7 +995,7 @@ func (q *Queries) SelectOffchainTxsInRange(ctx context.Context, arg SelectOffcha } const selectOnchainSpentVtxos = `-- name: SelectOnchainSpentVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = true AND spent = true +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = '' ` @@ -2114,7 +2114,7 @@ func (q *Queries) SelectTxs(ctx context.Context, dollar_1 []string) ([]SelectTxs } const selectUnrolledUnspentVtxos = `-- name: SelectUnrolledUnspentVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = true AND spent = false AND swept = false +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = false AND swept = false ` type SelectUnrolledUnspentVtxosRow struct { @@ -2719,7 +2719,7 @@ func (q *Queries) UpdateVtxoMarkers(ctx context.Context, arg UpdateVtxoMarkersPa const updateVtxoOnchainSpent = `-- name: UpdateVtxoOnchainSpent :exec UPDATE vtxo SET spent = true, spent_by = $1, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT -WHERE txid = $2 AND vout = $3 AND unrolled = true AND spent = false +WHERE txid = $2 AND vout = $3 AND (unrolled = true OR vtxo_kind = 1) AND spent = false ` type UpdateVtxoOnchainSpentParams struct { @@ -2733,6 +2733,9 @@ type UpdateVtxoOnchainSpentParams struct { // The spent = false guard makes the write idempotent and prevents it clobbering // an offchain spend that lands concurrently, which would erase that vtxo's // ark_txid and hide a genuine fraud case from the sweeper. +// vtxo_kind 1 is an on-chain Arkade UTXO (add_vtxo_kind migration). It has an +// onchain output without ever being unrolled, so the onchain spend statements +// take it alongside unrolled vtxos. func (q *Queries) UpdateVtxoOnchainSpent(ctx context.Context, arg UpdateVtxoOnchainSpentParams) error { _, err := q.db.ExecContext(ctx, updateVtxoOnchainSpent, arg.SpentBy, arg.Txid, arg.Vout) return err @@ -2740,7 +2743,7 @@ func (q *Queries) UpdateVtxoOnchainSpent(ctx context.Context, arg UpdateVtxoOnch const updateVtxoOnchainSpentBy = `-- name: UpdateVtxoOnchainSpentBy :exec UPDATE vtxo SET spent_by = $1, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT -WHERE txid = $2 AND vout = $3 AND unrolled = true AND spent = true +WHERE txid = $2 AND vout = $3 AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = '' ` @@ -2760,7 +2763,7 @@ func (q *Queries) UpdateVtxoOnchainSpentBy(ctx context.Context, arg UpdateVtxoOn const updateVtxoOnchainUnspent = `-- name: UpdateVtxoOnchainUnspent :exec UPDATE vtxo SET spent = false, spent_by = NULL, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT -WHERE txid = $1 AND vout = $2 AND unrolled = true AND spent = true +WHERE txid = $1 AND vout = $2 AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = '' ` diff --git a/internal/infrastructure/db/postgres/sqlc/query.sql b/internal/infrastructure/db/postgres/sqlc/query.sql index d299b7bcb..2b9859164 100644 --- a/internal/infrastructure/db/postgres/sqlc/query.sql +++ b/internal/infrastructure/db/postgres/sqlc/query.sql @@ -117,16 +117,19 @@ WHERE txid = @txid AND vout = @vout; -- The spent = false guard makes the write idempotent and prevents it clobbering -- an offchain spend that lands concurrently, which would erase that vtxo's -- ark_txid and hide a genuine fraud case from the sweeper. +-- vtxo_kind 1 is an on-chain Arkade UTXO (add_vtxo_kind migration). It has an +-- onchain output without ever being unrolled, so the onchain spend statements +-- take it alongside unrolled vtxos. -- name: UpdateVtxoOnchainSpent :exec UPDATE vtxo SET spent = true, spent_by = @spent_by, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT -WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = false; +WHERE txid = @txid AND vout = @vout AND (unrolled = true OR vtxo_kind = 1) AND spent = false; -- Re-points an already onchain-spent vtxo at a new spending tx, for when RBF -- replaces the spender. Scoped to rows that are onchain-spent so it can never -- rewrite the spent_by of an offchain spend or a settlement. -- name: UpdateVtxoOnchainSpentBy :exec UPDATE vtxo SET spent_by = @spent_by, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT -WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = true +WHERE txid = @txid AND vout = @vout AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = ''; -- Retracts an onchain spend whose transaction was evicted or reorged out. Same @@ -134,7 +137,7 @@ WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = true -- by this statement. -- name: UpdateVtxoOnchainUnspent :exec UPDATE vtxo SET spent = false, spent_by = NULL, updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT -WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = true +WHERE txid = @txid AND vout = @vout AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = ''; -- name: SelectRoundWithId :many @@ -426,12 +429,12 @@ SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE spent = true AND unrolled = true A -- Candidates for onchain-spend reconciliation: unrolled vtxos we currently -- believe are unspent. -- name: SelectUnrolledUnspentVtxos :many -SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE unrolled = true AND spent = false AND swept = false; +SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = false AND swept = false; -- Vtxos we currently record as spent onchain, so the reconciler can re-point -- them on RBF or retract them if the spend disappears. -- name: SelectOnchainSpentVtxos :many -SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE unrolled = true AND spent = true +SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = ''; -- name: SelectPendingSpentVtxosWithPubkeys :many diff --git a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go index 22101a48d..3ac5b45d5 100644 --- a/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go +++ b/internal/infrastructure/db/sqlite/sqlc/queries/query.sql.go @@ -1086,7 +1086,7 @@ func (q *Queries) SelectOffchainTxsInRange(ctx context.Context, arg SelectOffcha } const selectOnchainSpentVtxos = `-- name: SelectOnchainSpentVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = true AND spent = true +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = true AND (COALESCE(settled_by, '') = '') AND (COALESCE(ark_txid, '') = '') ` @@ -2190,7 +2190,7 @@ func (q *Queries) SelectTxs(ctx context.Context, arg SelectTxsParams) ([]SelectT } const selectUnrolledUnspentVtxos = `-- name: SelectUnrolledUnspentVtxos :many -SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE unrolled = true AND spent = false AND swept = false +SELECT vtxo_vw.txid, vtxo_vw.vout, vtxo_vw.pubkey, vtxo_vw.amount, vtxo_vw.expires_at, vtxo_vw.created_at, vtxo_vw.commitment_txid, vtxo_vw.spent_by, vtxo_vw.spent, vtxo_vw.unrolled, vtxo_vw.preconfirmed, vtxo_vw.settled_by, vtxo_vw.ark_txid, vtxo_vw.intent_id, vtxo_vw.updated_at, vtxo_vw.depth, vtxo_vw.markers, vtxo_vw.vtxo_kind, vtxo_vw.commitments, vtxo_vw.swept, vtxo_vw.asset_id, vtxo_vw.asset_amount FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = false AND swept = false ` type SelectUnrolledUnspentVtxosRow struct { @@ -2907,7 +2907,7 @@ func (q *Queries) UpdateVtxoMarkers(ctx context.Context, arg UpdateVtxoMarkersPa const updateVtxoOnchainSpent = `-- name: UpdateVtxoOnchainSpent :exec UPDATE vtxo SET spent = true, spent_by = ?1, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)) -WHERE txid = ?2 AND vout = ?3 AND unrolled = true AND spent = false +WHERE txid = ?2 AND vout = ?3 AND (unrolled = true OR vtxo_kind = 1) AND spent = false ` type UpdateVtxoOnchainSpentParams struct { @@ -2921,6 +2921,9 @@ type UpdateVtxoOnchainSpentParams struct { // The spent = false guard makes the write idempotent and prevents it clobbering // an offchain spend that lands concurrently, which would erase that vtxo's // ark_txid and hide a genuine fraud case from the sweeper. +// vtxo_kind 1 is an on-chain Arkade UTXO (add_vtxo_kind migration). It has an +// onchain output without ever being unrolled, so the onchain spend statements +// take it alongside unrolled vtxos. func (q *Queries) UpdateVtxoOnchainSpent(ctx context.Context, arg UpdateVtxoOnchainSpentParams) error { _, err := q.db.ExecContext(ctx, updateVtxoOnchainSpent, arg.SpentBy, arg.Txid, arg.Vout) return err @@ -2928,7 +2931,7 @@ func (q *Queries) UpdateVtxoOnchainSpent(ctx context.Context, arg UpdateVtxoOnch const updateVtxoOnchainSpentBy = `-- name: UpdateVtxoOnchainSpentBy :exec UPDATE vtxo SET spent_by = ?1, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)) -WHERE txid = ?2 AND vout = ?3 AND unrolled = true AND spent = true +WHERE txid = ?2 AND vout = ?3 AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = '' ` @@ -2948,7 +2951,7 @@ func (q *Queries) UpdateVtxoOnchainSpentBy(ctx context.Context, arg UpdateVtxoOn const updateVtxoOnchainUnspent = `-- name: UpdateVtxoOnchainUnspent :exec UPDATE vtxo SET spent = false, spent_by = NULL, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)) -WHERE txid = ?1 AND vout = ?2 AND unrolled = true AND spent = true +WHERE txid = ?1 AND vout = ?2 AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = '' ` diff --git a/internal/infrastructure/db/sqlite/sqlc/query.sql b/internal/infrastructure/db/sqlite/sqlc/query.sql index d0fa19952..c840d5e78 100644 --- a/internal/infrastructure/db/sqlite/sqlc/query.sql +++ b/internal/infrastructure/db/sqlite/sqlc/query.sql @@ -117,16 +117,19 @@ WHERE txid = @txid AND vout = @vout; -- The spent = false guard makes the write idempotent and prevents it clobbering -- an offchain spend that lands concurrently, which would erase that vtxo's -- ark_txid and hide a genuine fraud case from the sweeper. +-- vtxo_kind 1 is an on-chain Arkade UTXO (add_vtxo_kind migration). It has an +-- onchain output without ever being unrolled, so the onchain spend statements +-- take it alongside unrolled vtxos. -- name: UpdateVtxoOnchainSpent :exec UPDATE vtxo SET spent = true, spent_by = @spent_by, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)) -WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = false; +WHERE txid = @txid AND vout = @vout AND (unrolled = true OR vtxo_kind = 1) AND spent = false; -- Re-points an already onchain-spent vtxo at a new spending tx, for when RBF -- replaces the spender. Scoped to rows that are onchain-spent so it can never -- rewrite the spent_by of an offchain spend or a settlement. -- name: UpdateVtxoOnchainSpentBy :exec UPDATE vtxo SET spent_by = @spent_by, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)) -WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = true +WHERE txid = @txid AND vout = @vout AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = ''; -- Retracts an onchain spend whose transaction was evicted or reorged out. Same @@ -134,7 +137,7 @@ WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = true -- by this statement. -- name: UpdateVtxoOnchainUnspent :exec UPDATE vtxo SET spent = false, spent_by = NULL, updated_at = (CAST((strftime('%s','now') || substr(strftime('%f','now'),4,3)) AS INTEGER)) -WHERE txid = @txid AND vout = @vout AND unrolled = true AND spent = true +WHERE txid = @txid AND vout = @vout AND (unrolled = true OR vtxo_kind = 1) AND spent = true AND COALESCE(settled_by, '') = '' AND COALESCE(ark_txid, '') = ''; -- name: SelectRoundWithId :many @@ -437,12 +440,12 @@ SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE spent = true AND unrolled = true A -- Candidates for onchain-spend reconciliation: unrolled vtxos we currently -- believe are unspent. -- name: SelectUnrolledUnspentVtxos :many -SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE unrolled = true AND spent = false AND swept = false; +SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = false AND swept = false; -- Vtxos we currently record as spent onchain, so the reconciler can re-point -- them on RBF or retract them if the spend disappears. -- name: SelectOnchainSpentVtxos :many -SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE unrolled = true AND spent = true +SELECT sqlc.embed(vtxo_vw) FROM vtxo_vw WHERE (unrolled = true OR vtxo_kind = 1) AND spent = true AND (COALESCE(settled_by, '') = '') AND (COALESCE(ark_txid, '') = ''); -- name: SelectPendingSpentVtxosWithPubkeys :many