Skip to content
Open
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
5 changes: 3 additions & 2 deletions db/sqlc/querier.go

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

7 changes: 4 additions & 3 deletions db/sqlc/queries/unilateral_exit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ WHERE target_outpoint_hash = $1
;

-- name: ListNonTerminalUnilateralExitJobs :many
-- Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable (anchored to Go
-- iota in db/unilateral_exit_store.go UnilateralExitJobStatus).
-- Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable, 8 =
-- FailedConflicted (anchored to Go iota in db/unilateral_exit_store.go
-- UnilateralExitJobStatus). All four are terminal and excluded from restore.
SELECT * FROM unilateral_exit_jobs
WHERE status NOT IN (4, 5, 7)
WHERE status NOT IN (4, 5, 7, 8)
ORDER BY created_at ASC
;

Expand Down
7 changes: 4 additions & 3 deletions db/sqlc/unilateral_exit.sql.go

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

15 changes: 14 additions & 1 deletion db/unilateral_exit_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,26 @@ const (
// the exit has begun on-chain) so boot-time reconciliation can decide
// whether to recover the VTXO (wavelength#602).
UnilateralExitJobStatusFailedRecoverable

// UnilateralExitJobStatusFailedConflicted means the job failed
// terminally because a confirmed foreign spend conflicts with the
// recovery tree — the operator swept a source batch commitment output
// the exit depends on, so the exit can never complete. Unlike a plain
// Failed job, boot-time reconciliation must retire the target VTXO out
// of unilateral-exit (clearing it from pending balance) rather than
// leaving it pending forever, and unlike a recoverable failure it must
// NOT roll the VTXO back to live: the coin is provably gone
// (wavelength#1050). Appended after the original enum so existing rows'
// numeric meaning never shifts.
UnilateralExitJobStatusFailedConflicted
)

// IsTerminal reports whether the control-plane job status is terminal.
func (s UnilateralExitJobStatus) IsTerminal() bool {
return s == UnilateralExitJobStatusCompleted ||
s == UnilateralExitJobStatusFailed ||
s == UnilateralExitJobStatusFailedRecoverable
s == UnilateralExitJobStatusFailedRecoverable ||
s == UnilateralExitJobStatusFailedConflicted
}

// UnilateralExitJobTrigger records what started an exit job.
Expand Down
62 changes: 62 additions & 0 deletions lib/recovery/proof.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,68 @@ func (p *Proof) RootTxids() []chainhash.Hash {
return append([]chainhash.Hash(nil), p.layers[0]...)
}

// RootExternalInputs returns the outpoints consumed by root transactions that
// are NOT themselves produced by any node in this proof — the external funding
// inputs the whole recovery graph hangs off of. For a round-direct VTXO this is
// the batch/commitment output the tree root spends; for an OOR-chained or
// multi-input fan-in VTXO it is every distinct commitment output rooting a
// local lineage fragment.
//
// These are exactly the outpoints a competing party (an operator sweeping an
// expired batch, a fraud spend) can consume out from under the exit: a
// confirmed foreign spend of any one of them makes every root that depends on
// it — and therefore the whole proof — permanently unbroadcastable. Callers arm
// spend watches on them so such a conflict fails the exit terminally instead of
// spinning forever on a tree that can never confirm (wavelength#1050).
//
// The result is deduplicated and sorted deterministically (by hash then index)
// so two proofs built from the same node set yield identical output regardless
// of map iteration order.
func (p *Proof) RootExternalInputs() []wire.OutPoint {
seen := make(map[wire.OutPoint]struct{})
external := make([]wire.OutPoint, 0)

for _, rootTxid := range p.RootTxids() {
root, ok := p.nodes[rootTxid]
if !ok || root.Tx == nil {
continue
}

for _, txIn := range root.Tx.TxIn {
if txIn == nil {
continue
}

outpoint := txIn.PreviousOutPoint

// An input produced by another node is an in-graph
// dependency, not an external funding input.
if _, isNode := p.nodes[outpoint.Hash]; isNode {
continue
}

if _, dup := seen[outpoint]; dup {
continue
}

seen[outpoint] = struct{}{}
external = append(external, outpoint)
}
}

sort.Slice(external, func(i, j int) bool {
if c := bytes.Compare(
external[i].Hash[:], external[j].Hash[:],
); c != 0 {
return c < 0
}

return external[i].Index < external[j].Index
})

return external
}

// Layer returns the topological layer index for the requested txid. Layer 0
// is the set of roots; the target node's layer is always the maximum layer
// index.
Expand Down
48 changes: 48 additions & 0 deletions lib/recovery/proof_accessors_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package recovery

import (
"bytes"
"testing"

"github.com/btcsuite/btcd/chainhash/v2"
Expand Down Expand Up @@ -167,3 +168,50 @@ func TestProofAccessors(t *testing.T) {
_, err = proof.ChildTxids(chainhash.Hash{0xff})
require.ErrorContains(t, err, "unknown txid")
}

// TestProofRootExternalInputs verifies that RootExternalInputs returns exactly
// the external funding inputs of the proof's root transactions — the outpoints
// a competing spend can conflict with — deduplicated, deterministically
// ordered, and excluding in-graph (node-produced) inputs.
func TestProofRootExternalInputs(t *testing.T) {
// Two independent roots (a fan-in lineage), each spending its own
// external commitment output, feeding one target.
rootA := makeProofTx('a', nil)
rootB := makeProofTx('b', nil)
target := makeProofTx('t', []wire.OutPoint{
{Hash: rootA.TxHash(), Index: 0},
{Hash: rootB.TxHash(), Index: 0},
})

proof, err := NewProof(
wire.OutPoint{
Hash: target.TxHash(),
},
5, &Node{
Kind: NodeKindTree,
Tx: rootA,
}, &Node{
Kind: NodeKindTree,
Tx: rootB,
}, &Node{
Kind: NodeKindArk,
Tx: target,
},
)
require.NoError(t, err)

// makeProofTx seeds a root's external input at {Hash{tag,0xff}, tag}.
wantA := wire.OutPoint{Hash: chainhash.Hash{'a', 0xff}, Index: 'a'}
wantB := wire.OutPoint{Hash: chainhash.Hash{'b', 0xff}, Index: 'b'}

got := proof.RootExternalInputs()

// Both roots' external inputs are present; the target's in-graph inputs
// (produced by rootA/rootB) are excluded.
require.Len(t, got, 2)
require.Contains(t, got, wantA)
require.Contains(t, got, wantB)

// Deterministic ordering: sorted by hash bytes then index.
require.True(t, bytes.Compare(got[0].Hash[:], got[1].Hash[:]) <= 0)
}
Loading
Loading