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
15 changes: 10 additions & 5 deletions arkrpc/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ package arkrpc
// expectations.
//
// It is negotiated through the direct GetInfo bootstrap RPC and bound to a
// client runtime for its lifetime. Production currently supports only v1; a
// synthetic v2 may be configured in tests to exercise selection, binding, and
// rejection behavior, but no production default advertises a version beyond
// v1. This constant is deliberately separate from the mailbox transport
// version and the VTXO construction version, which evolve independently.
// client runtime for its lifetime. This constant is deliberately separate
// from the mailbox transport version and the VTXO construction version, which
// evolve independently.
const ArkProtocolVersionV1 uint32 = 1

// ArkProtocolVersionV2 identifies the complete one-confirmation reorg-safety
// contract: durable lineage registration, fail-closed admission, reversible
// effects through the negotiated policy horizon, and recovery on both peers.
// Defining the compatibility boundary does not enable it; clients must not
// advertise v2 until every required gate and end-to-end proof is present.
const ArkProtocolVersionV2 uint32 = 2
8 changes: 1 addition & 7 deletions chainsource/chainsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,7 @@ const (
epochChannelSize = 10

// DefaultFinalityDepth is the conventional Bitcoin reorg-safety
// depth. After this many inclusive confirmations the ConfActor
// and SpendActor synthesize their Done events when the backend
// transport (notably lndclient over gRPC) does not surface one
// of its own. Six is the value the wider Lightning stack treats
// as final for the purposes of channel funding / settlement, so
// using it here keeps the unroll subsystem's finality threshold
// aligned with the rest of the daemon's chain assumptions.
// depth. Daemon policy may override it through ChainSourceConfig.
DefaultFinalityDepth uint32 = 6
)

Expand Down
2 changes: 1 addition & 1 deletion chainsource/conf_actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type ConfActorConfig struct {
//
// The depth is counted inclusively (a tx confirmed at height H is
// at depth 1; height-based finality fires once the actor observes
// a block at H + FinalityDepth - 1). The conventional choice is 6.
// a block at H + FinalityDepth - 1).
FinalityDepth uint32
}

Expand Down
59 changes: 43 additions & 16 deletions db/boarding_sweep_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,36 @@ func (b *BoardingWalletStore) MarkBoardingSweepFailed(ctx context.Context,
now := b.clock.Now().Unix()

return b.db.ExecTx(ctx, WriteTxOption(), func(q BoardingStore) error {
sweep, err := q.GetBoardingSweep(ctx, txid[:])
if err != nil {
return fmt.Errorf("get sweep: %w", err)
}
if sweep.Status != sweepStatusPending &&
sweep.Status != sweepStatusPublished {

// Terminal evidence is monotonic. A duplicate or stale
// failure notification must never downgrade a
// confirmed/external sweep or release any of the value
// it consumed.
return nil
}

inputs, err := q.ListBoardingSweepInputs(ctx, txid[:])
if err != nil {
return fmt.Errorf("list sweep inputs: %w", err)
}

for _, input := range inputs {
if input.Status != inputStatusPending &&
input.Status != inputStatusPublished {

// A terminal spend is objective chain evidence.
// A later local broadcaster failure may release
// the other inputs, but must never restore this
// intent to a spendable state.
continue
}

err = q.UpdateBoardingIntentStatus(
ctx, sqlc.UpdateBoardingIntentStatusParams{
OutpointHash: input.OutpointHash,
Expand Down Expand Up @@ -374,6 +398,22 @@ func (b *BoardingWalletStore) MarkBoardingSweepInputSpent(ctx context.Context,
return sql.ErrNoRows
}

// Finality for one input is sufficient to make that specific
// boarding intent permanently spent, even while sibling inputs
// are still unresolved. This prevents a later sweep-level
// failure from restoring an objectively consumed intent.
err = q.UpdateBoardingIntentStatus(
ctx, sqlc.UpdateBoardingIntentStatusParams{
OutpointHash: outpoint.Hash[:],
OutpointIndex: int32(outpoint.Index),
Status: "swept",
LastUpdateTime: now,
},
)
if err != nil {
return fmt.Errorf("mark intent swept: %w", err)
}

count, err := q.CountUnresolvedBoardingSweepInputs(
ctx, sweepTxid[:],
)
Expand All @@ -389,24 +429,11 @@ func (b *BoardingWalletStore) MarkBoardingSweepInputSpent(ctx context.Context,
if err != nil {
return fmt.Errorf("list sweep inputs: %w", err)
}
for _, input := range inputs {
err = q.UpdateBoardingIntentStatus(
ctx, sqlc.UpdateBoardingIntentStatusParams{
OutpointHash: input.OutpointHash,
OutpointIndex: input.OutpointIndex,
Status: "swept",
LastUpdateTime: now,
},
)
if err != nil {
return fmt.Errorf("mark intent swept: %w", err)
}
}

sweepStatus := sweepStatusExternalResolved
sweepStatus := sweepStatusConfirmed
for _, input := range inputs {
if input.Status == inputStatusSpent {
sweepStatus = sweepStatusConfirmed
if input.Status != inputStatusSpent {
sweepStatus = sweepStatusExternalResolved
break
}
}
Expand Down
109 changes: 109 additions & 0 deletions db/boarding_wallet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package db

import (
"database/sql"
"errors"
"testing"

btcaddr "github.com/btcsuite/btcd/address/v2"
Expand Down Expand Up @@ -822,6 +823,94 @@ func TestBoardingSweepFailedRestoresIntent(t *testing.T) {
require.Equal(t, wallet.BoardingStatusConfirmed, updated.Status)
}

// TestBoardingSweepFailedPreservesTerminalInput verifies that a local
// sweep-level failure restores only unresolved siblings. An input with a
// final external spend must remain unavailable forever.
func TestBoardingSweepFailedPreservesTerminalInput(t *testing.T) {
t.Parallel()

ctx := t.Context()
store, _ := newBoardingStoreForTest(t)
terminal := createSweepStoreIntentWithSeed(t, store, 31)
unresolved := createSweepStoreIntentWithSeed(t, store, 32)

sweepTx := wire.NewMsgTx(2)
sweepTx.AddTxIn(&wire.TxIn{
PreviousOutPoint: terminal.Outpoint,
})
sweepTx.AddTxIn(&wire.TxIn{
PreviousOutPoint: unresolved.Outpoint,
})
sweepTx.AddTxOut(&wire.TxOut{
Value: 19_000,
PkScript: []byte{txscript.OP_TRUE},
})
sweepTxid := sweepTx.TxHash()

err := store.CreatePendingBoardingSweep(ctx, wallet.NewBoardingSweep{
Tx: sweepTx,
TotalAmount: 20_000,
FeeAmount: 1_000,
VBytes: 300,
Inputs: []wallet.NewBoardingSweepInput{
{
Outpoint: terminal.Outpoint,
Amount: terminal.ChainInfo.Amount,
PreviousStatus: terminal.Status,
},
{
Outpoint: unresolved.Outpoint,
Amount: unresolved.ChainInfo.Amount,
PreviousStatus: unresolved.Status,
},
},
})
require.NoError(t, err)

externalTxid := chainhash.Hash{0xfa}
resolved, err := store.MarkBoardingSweepInputSpent(
ctx, terminal.Outpoint, externalTxid, 333,
)
require.NoError(t, err)
require.False(t, resolved)

terminalIntent, err := store.GetIntent(ctx, terminal.Outpoint)
require.NoError(t, err)
require.Equal(t, wallet.BoardingStatusSwept, terminalIntent.Status)

err = store.MarkBoardingSweepFailed(
ctx, sweepTxid, errors.New("conflicting input"),
)
require.NoError(t, err)

terminalIntent, err = store.GetIntent(ctx, terminal.Outpoint)
require.NoError(t, err)
require.Equal(t, wallet.BoardingStatusSwept, terminalIntent.Status)

unresolvedIntent, err := store.GetIntent(ctx, unresolved.Outpoint)
require.NoError(t, err)
require.Equal(
t, wallet.BoardingStatusConfirmed, unresolvedIntent.Status,
)

record, err := store.GetBoardingSweep(ctx, sweepTxid)
require.NoError(t, err)
require.NotNil(t, record)
require.Equal(t, wallet.BoardingSweepStatusFailed, record.Status)
statusByOutpoint := make(map[wire.OutPoint]string, len(record.Inputs))
for _, input := range record.Inputs {
statusByOutpoint[input.Outpoint] = input.Status
}
require.Equal(
t, wallet.BoardingSweepInputStatusExternalSpent,
statusByOutpoint[terminal.Outpoint],
)
require.Equal(
t, wallet.BoardingSweepInputStatusFailed,
statusByOutpoint[unresolved.Outpoint],
)
}

// TestBoardingSweepExternalSpendResolvesSeparately verifies an externally
// spent sweep input does not make the aggregate look confirmed by our tx.
func TestBoardingSweepExternalSpendResolvesSeparately(t *testing.T) {
Expand Down Expand Up @@ -880,6 +969,26 @@ func TestBoardingSweepExternalSpendResolvesSeparately(t *testing.T) {
)
require.NoError(t, err)
require.Empty(t, confirmed)

// A stale broadcaster failure cannot overwrite the objective terminal
// spend or restore the consumed boarding intent.
err = store.MarkBoardingSweepFailed(
ctx, sweepTxid, errors.New("late broadcaster failure"),
)
require.NoError(t, err)

record, err := store.GetBoardingSweep(ctx, sweepTxid)
require.NoError(t, err)
require.Equal(
t, wallet.BoardingSweepStatusExternalResolved, record.Status,
)
require.Equal(
t, wallet.BoardingSweepInputStatusExternalSpent,
dbSweepInputStatus(t, record.Inputs),
)
updatedIntent, err := store.GetIntent(ctx, intent.Outpoint)
require.NoError(t, err)
require.Equal(t, wallet.BoardingStatusSwept, updatedIntent.Status)
}

// TestActiveBoardingSweepInputUnique verifies the DB enforces that one
Expand Down
13 changes: 13 additions & 0 deletions sample-waved.conf
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
# btcwallet, and unknown signers. Set one to force serial signing.
# signingworkers=0

# Deepest chain replacement for which provisional evidence remains
# recoverable. Terminal finality begins one confirmation later. The zero value
# uses the default of 30; the current cross-backend maximum is 144.
# reorgsafetydepth=30

# Explicit opt-in for mainnet operation.
# allow-mainnet=false

Expand Down Expand Up @@ -212,6 +217,14 @@
# Maximum unroll fee rate in sat/vB. The zero value uses the unroll default.
# unroll.maxfeeratesatpervbyte=0

# Per-anchor restart-reconciliation probe timeout in seconds. The chainsource-
# backed ChainReconciler issues one probe per persisted anchor on Resume; a
# probe that exceeds this budget leaves the durable checkpoint unchanged and
# fails closed for a later retry. Operators running against a slow chain
# backend can raise the budget. The zero value uses the reconciler's internal
# default (10s).
# unroll.reconcileprobetimeoutsec=0

# Swap server address for swapruntime builds. Empty uses the network+transport
# default; see docs/signet.md for the public test-network endpoints.
# swap.serveraddress=
Expand Down
Loading