Skip to content
Open
24 changes: 23 additions & 1 deletion internal/core/domain/vtxo.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,31 @@ 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, "", " ")
return string(b)
}

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 {
Expand Down Expand Up @@ -102,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))
}
19 changes: 19 additions & 0 deletions internal/core/domain/vtxo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -114,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) {
Expand Down
2 changes: 2 additions & 0 deletions internal/infrastructure/db/postgres/marker_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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),
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions internal/infrastructure/db/postgres/round_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,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),
}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/infrastructure/db/postgres/sqlc/queries/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading