Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions internal/core/application/onchain_spend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
18 changes: 18 additions & 0 deletions internal/core/application/onchain_spend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions internal/core/application/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
47 changes: 39 additions & 8 deletions internal/core/domain/vtxo.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,33 +65,57 @@ 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 {
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 {
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) {
Expand All @@ -111,5 +135,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))
}
10 changes: 6 additions & 4 deletions internal/core/domain/vtxo_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
48 changes: 48 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 Expand Up @@ -172,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) {
Expand Down Expand Up @@ -217,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) {
Expand Down
22 changes: 19 additions & 3 deletions internal/infrastructure/db/badger/vtxo_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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() {
Expand Down
79 changes: 79 additions & 0 deletions internal/infrastructure/db/onchain_spend_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
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
Loading