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
313 changes: 204 additions & 109 deletions arkrpc/indexer.pb.go

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions arkrpc/indexer.proto
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,27 @@ message AncestryPath {
// tx anchoring this fragment. Zero means unknown (legacy/unconfirmed),
// in which case the client falls back to a bounded lookback floor.
int32 commitment_height = 5;

// commitment_tx is the serialized commitment transaction. Its hash must
// equal commitment_txid and tree_path.batch_outpoint.txid.
bytes commitment_tx = 6;

// commitment_inputs contains one previous-output record for every actual
// commitment transaction input, in transaction input order.
repeated CommitmentInputEvidence commitment_inputs = 7;

// commitment_csv_expiry_delta is the batch sweep delay in blocks.
int32 commitment_csv_expiry_delta = 8;
}

// CommitmentInputEvidence binds one commitment input to the full previous
// output needed for authenticated conflict observation.
message CommitmentInputEvidence {
// outpoint must equal the corresponding commitment transaction input.
OutPoint outpoint = 1;

// prev_out supplies the value and pkScript of the spent output.
TxOut prev_out = 2;
}

// ScriptScope selects a pkScript and carries a proof-of-control for that
Expand Down
78 changes: 78 additions & 0 deletions batchcanon/admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,81 @@ func TestAdmissionFailsClosedOnObservationPersistenceError(t *testing.T) {
recovered.Token.Lineage[0].Revision,
)
}

// TestWiredGateFailsClosedViaManagerOverlay proves the WIRED admission gate —
// which the VTXO manager drives through Manager.GetBatch / LineageBlocked, not
// the actor QueryLineage path — fails closed after a reorg observation whose
// durable write failed. Without the manager's GetBatch overlay the gate would
// read the stale durable row (still Ready) and admit a VTXO whose commitment
// just left the best chain.
func TestWiredGateFailsClosedViaManagerOverlay(t *testing.T) {
t.Parallel()

h := newManagerHarness(t, 100)
txid := testBatchTxid(0xd7)
input := testOutpoint(0xd8, 0)
h.registerBatch(t, &RegisterBatchRequest{
BatchTxID: txid,
ConfirmationPkScript: []byte{0x51, 0x20, 0xd7},
ConsumedInputs: []ConsumedInput{ci(input)},
})
h.fireConfirmed(t, txid, 101, testBatchTxid(0xe1))
h.fireSpend(t, input, txid, 101)

// The chain events above are async Tells flowing through the mock
// chainsource into the manager actor. Ask the actor (serialized after
// them) to synchronize before each direct GetBatch/LineageBlocked call
// so the wired-path assertions are not racing that delivery.
sync := func() *QueryLineageResponse {
t.Helper()
resp, err := h.mgrRef.Ask(
t.Context(), &QueryLineageRequest{
BatchTxIDs: []chainhash.Hash{txid},
},
).Await(t.Context()).Unpack()
require.NoError(t, err)
lineage, ok := resp.(*QueryLineageResponse)
require.True(t, ok)

return lineage
}
require.Equal(t, AvailableProvisional, sync().Availability)

// Baseline: the wired gate (LineageBlocked over the manager as Reader)
// admits the usable provisional lineage.
blocked, avail, err := LineageBlocked(t.Context(), h.mgr, txid)
require.NoError(t, err)
require.False(t, blocked)
require.Equal(t, AvailableProvisional, avail)

// A reorg observation whose durable write fails leaves the durable row
// stale (still Ready), but the in-memory overlay knows better.
h.store.setApplyError(errors.New("injected durable write failure"))
h.fireConfReorged(t, txid)

// The actor path already fails closed here (it reads the same overlay);
// this also synchronizes the reorg before the direct GetBatch below.
require.Equal(t, LineageReconciling, sync().Availability)

durable, err := h.store.GetBatch(t.Context(), txid)
require.NoError(t, err)
require.True(
t, durable.Ready(),
"precondition: the durable row stays stale-usable after "+
"the failed write",
)

// The wired gate reads through Manager.GetBatch, whose overlay forces
// the stale-ready row not-ready, so admission fails closed.
overlaid, err := h.mgr.GetBatch(t.Context(), txid)
require.NoError(t, err)
require.False(
t, overlaid.Ready(),
"manager overlay must force the stale-ready row not-ready",
)

blocked, avail, err = LineageBlocked(t.Context(), h.mgr, txid)
require.NoError(t, err)
require.True(t, blocked, "wired gate must refuse admission")
require.Equal(t, LineageReconciling, avail)
}
44 changes: 41 additions & 3 deletions batchcanon/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,9 @@ func validateRegistration(req *RegisterBatchRequest,
if len(req.ConfirmationPkScript) == 0 {
return fmt.Errorf("batch confirmation pkScript is required")
}
if req.CSVExpiryDelta <= 0 {
return fmt.Errorf("batch CSV expiry delta must be positive")
}
if len(req.ConsumedInputs) == 0 {
return fmt.Errorf("batch must register every consumed input")
}
Expand Down Expand Up @@ -1050,10 +1053,45 @@ func observationComplete(w *batchWatch) bool {
return true
}

// GetBatch implements batchcanon.Reader with the manager's in-memory overlay so
// the wired admission gate fails closed the instant a reorg/conflict
// observation cannot be persisted. The gate reads availability through this
// method; when an in-memory watch is not ready — most importantly after an
// ApplyObservation write failed in persistObservation — the returned record is
// forced not-ready, so LineageAvailability derives LineageReconciling (never
// usable) even though the stale durable row still reads ready. The overlay only
// ever downgrades ready->not-ready, never the reverse, so it is strictly
// fail-closed. Without it the gate would admit a VTXO whose commitment just
// left the best chain until a restart durably reconciled the row.
func (m *Manager) GetBatch(ctx context.Context, txid chainhash.Hash) (*Record,
error) {

record, err := m.cfg.Store.GetBatch(ctx, txid)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] We need to take m.mu before the store read here. Right now the read can return Provisional@r, then a reorg callback takes the mutex and successfully persists ReorgedOut@(r+1). That complete snapshot leaves w.ready=true, so the overlay below doesn't downgrade the stale record and the caller still admits it. I think the clean fix is to linearize the store read with the watch snapshot under the same lock, then add a deterministic interleaving test.

if err != nil {
return nil, err
}

m.mu.Lock()
w, watched := m.watches[txid]
overlayNotReady := watched && !w.ready
m.mu.Unlock()

if overlayNotReady && record.Ready() {
notReady := *record
notReady.ReadyGeneration = fn.None[uint64]()

return &notReady, nil
}

return record, nil
}

// persistObservation atomically writes the complete in-memory view. On any
// error the manager's overlay closes admission immediately, even if the old
// durable row was usable; restart reconciliation closes it durably before
// re-arming watches.
// error the in-memory watch is marked not-ready; the wired admission gate reads
// through Manager.GetBatch, whose overlay forces such a batch not-ready even
// though the stale durable row still reads ready, so admission closes
// immediately. Restart reconciliation then closes it durably before re-arming
// watches.
func (m *Manager) persistObservation(ctx context.Context, w *batchWatch) {
inputs := make([]InputObservation, 0, len(w.inputs))
for outpoint, input := range w.inputs {
Expand Down
3 changes: 3 additions & 0 deletions batchcanon/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,9 @@ func (h *managerHarness) registerBatch(t *testing.T,
if len(req.ConfirmationPkScript) == 0 {
req.ConfirmationPkScript = []byte{0x51}
}
if req.CSVExpiryDelta <= 0 {
req.CSVExpiryDelta = 144
}
if len(req.ConsumedInputs) == 0 {
req.ConsumedInputs = []ConsumedInput{ci(wire.OutPoint{
Hash: req.BatchTxID,
Expand Down
74 changes: 74 additions & 0 deletions batchcanon/record.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,85 @@
package batchcanon

import (
"bytes"
"fmt"

"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/wire/v2"
fn "github.com/lightningnetwork/lnd/fn/v2"
)

// BatchEvidence is the authenticated, behavior-free description of one
// commitment transaction required to register and observe its canonicality.
// Receive paths carry this separately from the VTXOs that depend on it so one
// batch can be reused across multi-parent OOR lineage.
type BatchEvidence struct {
// BatchTxID is the hash of BatchTx.
BatchTxID chainhash.Hash

// BatchTx is the serialized commitment transaction.
BatchTx []byte

// BatchOutputIndex selects the commitment output anchoring the tree.
BatchOutputIndex uint32

// ConfirmationPkScript is the selected commitment output script.
ConfirmationPkScript []byte

// WatchHeightHint is the earliest height from which watches must scan.
// Indexer-sourced evidence uses the observed commitment height; locally
// produced evidence uses the round's pre-broadcast start height.
WatchHeightHint uint32

// CSVExpiryDelta is the batch sweep delay in blocks.
CSVExpiryDelta int32

// ConsumedInputs binds every actual transaction input to its previous
// output value and script.
ConsumedInputs []ConsumedInput
}

// RegisterRequest builds an actor request that adds the supplied local VTXOs
// as dependents of this evidence without mutating the evidence itself.
func (e BatchEvidence) RegisterRequest(
dependentVTXOs []wire.OutPoint) *RegisterBatchRequest {

inputs := make([]ConsumedInput, len(e.ConsumedInputs))
for i := range e.ConsumedInputs {
inputs[i] = e.ConsumedInputs[i]
inputs[i].PkScript = bytes.Clone(e.ConsumedInputs[i].PkScript)
}

return &RegisterBatchRequest{
BatchTxID: e.BatchTxID,
BatchTx: bytes.Clone(e.BatchTx),
BatchOutputIndex: e.BatchOutputIndex,
ConfirmationPkScript: bytes.Clone(e.ConfirmationPkScript),
WatchHeightHint: e.WatchHeightHint,
CSVExpiryDelta: e.CSVExpiryDelta,
ConsumedInputs: inputs,
DependentVTXOs: append(
[]wire.OutPoint(nil), dependentVTXOs...,
),
}
}

// Validate checks the internal transaction, output, and input bindings. The
// manager repeats these checks at the durable registration boundary.
func (e BatchEvidence) Validate() error {
req := e.RegisterRequest(nil)
if err := validateRegistration(req, false); err != nil {
return fmt.Errorf("invalid batch evidence: %w", err)
}

return nil
}

// Equal reports whether both values carry identical immutable watch evidence.
func (e BatchEvidence) Equal(other BatchEvidence) bool {
return equalBatchEvidence(e, other)
}

// Record is the durable canonicality view of one batch (commitment)
// transaction, keyed by its txid. It bundles the interpreted State, the
// current confirmation observation, the recompute inputs for effective
Expand Down
8 changes: 8 additions & 0 deletions batchcanon/registration_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ func TestValidateRegistrationBindsSerializedEvidence(t *testing.T) {
},
want: "hash does not match",
},
{
name: "non-positive CSV expiry delta",
mutate: func(req *RegisterBatchRequest) {
req.CSVExpiryDelta = 0
},
want: "CSV expiry delta must be positive",
},
{
name: "trailing transaction bytes",
mutate: func(req *RegisterBatchRequest) {
Expand Down Expand Up @@ -128,6 +135,7 @@ func validRegistrationRequest(t *testing.T) *RegisterBatchRequest {
BatchTx: raw.Bytes(),
BatchOutputIndex: 1,
ConfirmationPkScript: watchScript,
CSVExpiryDelta: 144,
ConsumedInputs: []ConsumedInput{
{
Outpoint: inputA,
Expand Down
Loading
Loading