diff --git a/internal/core/application/boarding_validation_test.go b/internal/core/application/boarding_validation_test.go new file mode 100644 index 000000000..bb4b973bb --- /dev/null +++ b/internal/core/application/boarding_validation_test.go @@ -0,0 +1,253 @@ +package application + +import ( + "testing" + "time" + + "github.com/arkade-os/arkd/internal/core/ports" + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/ark-lib/script" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// Two boarding inputs can share one funding tx while carrying different +// tapscripts, exit delays and amounts, so every check in validateBoardingInput +// is per-output. These tests pin that: the same funding tx and block timestamp +// yield different verdicts depending only on which output is being spent. +func TestValidateBoardingInput(t *testing.T) { + signerKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + signer := signerKey.PubKey() + + ownerKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + owner := ownerKey.PubKey() + + now := time.Now() + // Confirmed an hour ago. CSV seconds must be a multiple of 512 (BIP68), so + // 7168s (~2h) is still locked while 1536s (~25m) has already matured. + confirmedAt := now.Add(-time.Hour) + blockTimestamp := &ports.BlockTimestamp{Height: 100, Time: confirmedAt.Unix()} + tip := &ports.BlockTimestamp{Height: 200, Time: now.Unix()} + + longDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 7168} + shortDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 1536} + + longScript := script.NewDefaultVtxoScript(owner, signer, longDelay) + shortScript := script.NewDefaultVtxoScript(owner, signer, shortDelay) + + longTapscripts, err := longScript.Encode() + require.NoError(t, err) + shortTapscripts, err := shortScript.Encode() + require.NoError(t, err) + + // One funding tx with two outputs, spent by two different boarding inputs. + tx := &wire.MsgTx{ + TxOut: []*wire.TxOut{ + {Value: 100_000}, + {Value: 100_000}, + }, + } + + settings := ports.Settings{ + SignerPubkey: signer, + } + settings.BoardingExitDelay = shortDelay + settings.UnilateralExitDelay = shortDelay + settings.UtxoMinAmount = 1_000 + settings.UtxoMaxAmount = 1_000_000 + + t.Run("vout 0 with a still-locked exit path passes", func(t *testing.T) { + err := validateBoardingInput(tx, blockTimestamp, tip, boardingInput(0, longTapscripts), now, settings) + require.NoError(t, err) + }) + + // The regression this guards: validation used to be memoized per funding + // txid, so a second input on the same tx skipped its own expiry check and a + // matured exit path slipped through. + t.Run("vout 1 with a matured exit path is rejected", func(t *testing.T) { + err := validateBoardingInput(tx, blockTimestamp, tip, boardingInput(1, shortTapscripts), now, settings) + require.ErrorContains(t, err, "expired") + }) + + t.Run("amount bounds are checked against the spent output", func(t *testing.T) { + tooSmall := &wire.MsgTx{ + TxOut: []*wire.TxOut{{Value: 100_000}, {Value: 10}}, + } + err := validateBoardingInput( + tooSmall, blockTimestamp, tip, boardingInput(1, longTapscripts), now, settings, + ) + require.ErrorContains(t, err, "lower than min utxo amount") + + tooBig := &wire.MsgTx{ + TxOut: []*wire.TxOut{{Value: 100_000}, {Value: 9_000_000}}, + } + err = validateBoardingInput( + tooBig, blockTimestamp, tip, boardingInput(1, longTapscripts), now, settings, + ) + require.ErrorContains(t, err, "higher than max utxo amount") + }) + + t.Run("vout past the end of the tx is rejected", func(t *testing.T) { + err := validateBoardingInput( + tx, blockTimestamp, tip, boardingInput(5, longTapscripts), now, settings, + ) + require.ErrorContains(t, err, "invalid vout index") + }) + + t.Run("unrolled vtxo needs margin before its exit matures", func(t *testing.T) { + withMargin := settings + withMargin.UnrolledVtxoMinExpiryMargin = 4 * time.Hour + + in := boardingInput(0, longTapscripts) + in.isUnrolledVtxo = true + + // The long delay leaves 1h of lock, which is inside a 4h margin. + err := validateBoardingInput(tx, blockTimestamp, tip, in, now, withMargin) + require.ErrorContains(t, err, "expires too soon") + }) + + // The unrolled-vtxo margin used to be measured against a csvExpiresAt built + // from RelativeLocktime.Seconds(), so a block-typed delay was read at + // SECONDS_PER_BLOCK = 1: a 144-block exit looked like it matured 144 seconds + // after confirmation, and every unrolled vtxo on a block-typed config was + // rejected as "expires too soon". Regtest runs block-typed + // (ARKD_VTXO_TREE_EXPIRY=40 is under the 512 threshold), so this was live. + t.Run("unrolled vtxo with a block-typed delay is measured in blocks", func(t *testing.T) { + blockDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: 144} + blockScript := script.NewDefaultVtxoScript(owner, signer, blockDelay) + blockTapscripts, err := blockScript.Encode() + require.NoError(t, err) + + blockSettings := settings + blockSettings.BoardingExitDelay = blockDelay + blockSettings.UnilateralExitDelay = blockDelay + blockSettings.VtxoTreeExpiry = blockDelay // makes AllowCSVBlockType() true + blockSettings.UnrolledVtxoMinExpiryMargin = 5 * time.Minute + + in := boardingInput(0, blockTapscripts) + in.isUnrolledVtxo = true + + // Confirmed at 100, matures at 244. A 5m margin is one block, so the + // threshold is tip+1+1 >= 244. At tip 200 there are ~43 blocks to go and + // the input must be accepted, which the seconds reading got wrong. + err = validateBoardingInput(tx, blockTimestamp, tip, in, now, blockSettings) + require.NoError(t, err) + + // At tip 242 the margin block bites: 242+1+1 == 244. + nearTip := &ports.BlockTimestamp{Height: 242, Time: now.Unix()} + err = validateBoardingInput(tx, blockTimestamp, nearTip, in, now, blockSettings) + require.ErrorContains(t, err, "expires too soon") + }) +} + +// boardingInput builds a boarding input for the given output index, with the +// locktime check disabled so tests exercise one rule at a time. +func boardingInput(vout uint32, tapscripts []string) boardingIntentInput { + in := boardingIntentInput{locktimeDisabled: true} + in.VOut = vout + in.Txid = "0000000000000000000000000000000000000000000000000000000000000001" + in.Tapscripts = tapscripts + return in +} + +// Block-typed relative locktimes must be evaluated in blocks. Routing them +// through RelativeLocktime.Seconds() converts at SECONDS_PER_BLOCK = 1, which +// would mature a 144-block exit 144 seconds after confirmation. +func TestExitPathAvailable(t *testing.T) { + now := time.Now() + conf := &ports.BlockTimestamp{Height: 100, Time: now.Add(-time.Hour).Unix()} + blockDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeBlock, Value: 144} + + // Per BIP68 an input confirmed at H with N blocks first becomes spendable + // in block H+N, so it is available once tip+1 >= H+N. Here H+N = 244. + t.Run("block delay boundary", func(t *testing.T) { + for _, tc := range []struct { + tipHeight uint32 + available bool + }{ + {242, false}, // next block 243 < 244 + {243, true}, // next block 244 == 244, first available tip + {244, true}, + } { + got, err := exitPathAvailable( + conf, &ports.BlockTimestamp{Height: tc.tipHeight}, blockDelay, 0, now, + ) + require.NoError(t, err) + require.Equalf(t, tc.available, got, "tip=%d", tc.tipHeight) + } + }) + + t.Run("margin rejects earlier than plain maturity", func(t *testing.T) { + tip := &ports.BlockTimestamp{Height: 240} + plain, err := exitPathAvailable(conf, tip, blockDelay, 0, now) + require.NoError(t, err) + require.False(t, plain) + + // 240+1+3 == 244, so a 3-block margin trips at a tip that is otherwise fine. + withMargin, err := exitPathAvailable(conf, tip, blockDelay, 3*targetBlockInterval, now) + require.NoError(t, err) + require.True(t, withMargin) + }) + + // The margin is a duration, so against a block-typed delay it has to be + // converted. Rounding down would silently drop every margin shorter than one + // block interval, which is most of them: the default is 5 minutes. + t.Run("duration margin converts to whole blocks, rounding up", func(t *testing.T) { + for _, tc := range []struct { + margin time.Duration + blocks int64 + }{ + {0, 0}, + {time.Nanosecond, 1}, + {5 * time.Minute, 1}, + {targetBlockInterval, 1}, + {targetBlockInterval + time.Nanosecond, 2}, + {3 * targetBlockInterval, 3}, + {-time.Hour, 0}, + } { + require.Equalf( + t, tc.blocks, blocksForDuration(tc.margin), "margin=%s", tc.margin, + ) + } + }) + + t.Run("block delay needs a tip and a real confirmation height", func(t *testing.T) { + _, err := exitPathAvailable(conf, nil, blockDelay, 0, now) + require.ErrorContains(t, err, "chain tip") + + _, err = exitPathAvailable( + &ports.BlockTimestamp{Height: 0}, &ports.BlockTimestamp{Height: 200}, + blockDelay, 0, now, + ) + require.ErrorContains(t, err, "confirmation height") + }) + + t.Run("seconds delay keeps wall-clock semantics", func(t *testing.T) { + secondsDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 1536} + // Confirmed an hour ago, so a 1536s lock has matured. + got, err := exitPathAvailable(conf, nil, secondsDelay, 0, now) + require.NoError(t, err) + require.True(t, got) + + longDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 7168} + got, err = exitPathAvailable(conf, nil, longDelay, 0, now) + require.NoError(t, err) + require.False(t, got) + }) + + t.Run("seconds delay honours the margin", func(t *testing.T) { + // 7168s from an hour ago leaves ~59m of lock. + longDelay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 7168} + + got, err := exitPathAvailable(conf, nil, longDelay, 30*time.Minute, now) + require.NoError(t, err) + require.False(t, got) + + got, err = exitPathAvailable(conf, nil, longDelay, 2*time.Hour, now) + require.NoError(t, err) + require.True(t, got) + }) +} diff --git a/internal/core/application/service.go b/internal/core/application/service.go index 66384ee2c..af026001b 100644 --- a/internal/core/application/service.go +++ b/internal/core/application/service.go @@ -4244,21 +4244,33 @@ func (s *service) processBoardingInputs( } boardingInputs := make([]ports.BoardingInput, 0) - boardingTxs := make(map[string]wire.MsgTx, 0) // txid -> txhex + // Only the funding tx and its confirmation are memoized per txid. The + // per-output validation below runs for every input, since two boarding + // utxos can share a funding tx while carrying different tapscripts, exit + // delays and amounts. + boardingTxs := make(map[string]fundingTx, 0) // txid -> funding tx now := time.Now() + // Fetched once per request: block-typed exit delays are evaluated against + // the chain tip, and the lookup is an uncached round trip to the wallet. + tip, err := s.wallet.GetCurrentBlockTime(ctx) + if err != nil { + return nil, errors.INTERNAL_ERROR.New("failed to get chain tip: %w", err) + } + for _, input := range boardingUtxos { - if _, ok := boardingTxs[input.Txid]; !ok { - if len(input.Tapscripts) == 0 { - return nil, errors.INVALID_PSBT_INPUT.New( - "missing taptree for input %s", input.Outpoint, - ).WithMetadata(errors.InputMetadata{ - Txid: intentTxid, - InputIndex: int(input.VOut), - }) - } + if len(input.Tapscripts) == 0 { + return nil, errors.INVALID_PSBT_INPUT.New( + "missing taptree for input %s", input.Outpoint, + ).WithMetadata(errors.InputMetadata{ + Txid: intentTxid, + InputIndex: int(input.VOut), + }) + } - tx, err := s.validateBoardingInput(ctx, input, now, settings) + funding, ok := boardingTxs[input.Txid] + if !ok { + fetchedTx, blockTimestamp, err := s.fetchConfirmedTx(ctx, input.Txid) if err != nil { return nil, errors.INVALID_PSBT_INPUT.New( "failed to validate boarding input: %w", err, @@ -4267,11 +4279,22 @@ func (s *service) processBoardingInputs( InputIndex: int(input.VOut), }) } + funding = fundingTx{tx: *fetchedTx, blockTimestamp: blockTimestamp} + boardingTxs[input.Txid] = funding + } - boardingTxs[input.Txid] = *tx + if err := validateBoardingInput( + &funding.tx, funding.blockTimestamp, tip, input, now, settings, + ); err != nil { + return nil, errors.INVALID_PSBT_INPUT.New( + "failed to validate boarding input: %w", err, + ).WithMetadata(errors.InputMetadata{ + Txid: intentTxid, + InputIndex: int(input.VOut), + }) } - tx := boardingTxs[input.Txid] + tx := funding.tx if int(input.VOut) >= len(tx.TxOut) { return nil, errors.INVALID_PSBT_INPUT.New( "invalid vout index %d for tx %s (tx has %d outputs)", @@ -4327,38 +4350,58 @@ func (s *service) processBoardingInputs( return boardingInputs, nil } -func (s *service) validateBoardingInput( - ctx context.Context, input boardingIntentInput, now time.Time, settings ports.Settings, -) (*wire.MsgTx, error) { - boardingExitDelay := settings.BoardingExitDelay - unilateralExitDelay := settings.UnilateralExitDelay - utxoMinAmount := settings.UtxoMinAmount - utxoMaxAmount := settings.UtxoMaxAmount - unrolledVtxoMinExpiryMargin := settings.UnrolledVtxoMinExpiryMargin - - vtxoScript, err := script.ParseVtxoScript(input.Tapscripts) - if err != nil { - return nil, err - } +// fundingTx is a boarding input's funding transaction plus its confirmation, +// both of which are per-transaction and so safe to memoize across inputs. +type fundingTx struct { + tx wire.MsgTx + blockTimestamp *ports.BlockTimestamp +} - // check if the tx exists and is confirmed - txhex, err := s.wallet.GetTransaction(ctx, input.Txid) +// fetchConfirmedTx retrieves a funding tx and asserts it is confirmed. Both are +// properties of the transaction, not of an individual output, so the caller may +// memoize the result per txid. +func (s *service) fetchConfirmedTx( + ctx context.Context, txid string, +) (*wire.MsgTx, *ports.BlockTimestamp, error) { + txhex, err := s.wallet.GetTransaction(ctx, txid) if err != nil { - return nil, fmt.Errorf("failed to get tx %s: %s", input.Txid, err) + return nil, nil, fmt.Errorf("failed to get tx %s: %s", txid, err) } var tx wire.MsgTx if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txhex))); err != nil { - return nil, fmt.Errorf("failed to deserialize tx %s: %s", input.Txid, err) + return nil, nil, fmt.Errorf("failed to deserialize tx %s: %s", txid, err) } - confirmed, blockTimestamp, err := s.wallet.IsTransactionConfirmed(ctx, input.Txid) + confirmed, blockTimestamp, err := s.wallet.IsTransactionConfirmed(ctx, txid) if err != nil { - return nil, fmt.Errorf("failed to check tx %s: %s", input.Txid, err) + return nil, nil, fmt.Errorf("failed to check tx %s: %s", txid, err) } if !confirmed { - return nil, fmt.Errorf("tx %s not confirmed", input.Txid) + return nil, nil, fmt.Errorf("tx %s not confirmed", txid) + } + + return &tx, blockTimestamp, nil +} + +// validateBoardingInput validates a single boarding input against its already +// fetched funding tx. Every check here depends on the specific output being +// spent (its tapscripts, its exit delay, its amount), so it must run for every +// input, even when several inputs share one funding tx. +func validateBoardingInput( + tx *wire.MsgTx, blockTimestamp, tip *ports.BlockTimestamp, + input boardingIntentInput, now time.Time, settings ports.Settings, +) error { + boardingExitDelay := settings.BoardingExitDelay + unilateralExitDelay := settings.UnilateralExitDelay + utxoMinAmount := settings.UtxoMinAmount + utxoMaxAmount := settings.UtxoMaxAmount + unrolledVtxoMinExpiryMargin := settings.UnrolledVtxoMinExpiryMargin + + vtxoScript, err := script.ParseVtxoScript(input.Tapscripts) + if err != nil { + return err } // validate the vtxo script @@ -4376,28 +4419,41 @@ func (s *service) validateBoardingInput( vtxoScript, settings.SignerPubkey, settings.DeprecatedSignerPubkeys, time.Now(), minAllowedCSV, settings.AllowCSVBlockType(), ); err != nil { - return nil, fmt.Errorf("invalid vtxo script: %s", err) + return fmt.Errorf("invalid vtxo script: %s", err) } exitDelay, err := vtxoScript.SmallestExitDelay() if err != nil { - return nil, fmt.Errorf("failed to get exit delay: %s", err) + return fmt.Errorf("failed to get exit delay: %s", err) } - // if the exit path is available, forbid registering the boarding utxo - csvExpiresAt := time.Unix(blockTimestamp.Time, 0). - Add(time.Duration(exitDelay.Seconds()) * time.Second) - if csvExpiresAt.Before(now) { - return nil, fmt.Errorf("tx %s expired", input.Txid) + // if the exit path is available, forbid registering the boarding utxo. + // No margin here: this gate is about an exit path that is already open. The + // on-chain confirmation-window setting is what will supply one (#1159). + available, err := exitPathAvailable(blockTimestamp, tip, *exitDelay, 0, now) + if err != nil { + return err + } + if available { + return fmt.Errorf("tx %s expired", input.Txid) } // For unrolled VTXOs, ensure the CSV is far enough from expiring so the - // batch has time to finalize before the exit path becomes available. + // batch has time to finalize before the exit path becomes available. This + // is the same question as above asked with a margin, so it goes through the + // same helper: computing it separately is what let block-typed delays be + // measured in seconds here. if input.isUnrolledVtxo { - if err := checkUnrolledVtxoExpiry( - csvExpiresAt, now, unrolledVtxoMinExpiryMargin, - ); err != nil { - return nil, err + expiresSoon, err := exitPathAvailable( + blockTimestamp, tip, *exitDelay, unrolledVtxoMinExpiryMargin, now, + ) + if err != nil { + return err + } + if expiresSoon { + return fmt.Errorf( + "unrolled vtxo CSV expires too soon (within %s)", unrolledVtxoMinExpiryMargin, + ) } } @@ -4409,14 +4465,14 @@ func (s *service) validateBoardingInput( Unix() - blockTimestamp.Time if diff := input.locktime.Seconds() - delta; diff > 0 { - return nil, fmt.Errorf( + return fmt.Errorf( "vtxo script can be used for intent registration in %d seconds", diff, ) } } if int(input.VOut) >= len(tx.TxOut) { - return nil, fmt.Errorf( + return fmt.Errorf( "invalid vout index %d for tx %s (tx has %d outputs)", input.VOut, input.Txid, len(tx.TxOut), ) @@ -4424,28 +4480,17 @@ func (s *service) validateBoardingInput( if utxoMaxAmount >= 0 { if tx.TxOut[input.VOut].Value > utxoMaxAmount { - return nil, fmt.Errorf( + return fmt.Errorf( "boarding input amount is higher than max utxo amount:%d", utxoMaxAmount, ) } } if tx.TxOut[input.VOut].Value < utxoMinAmount { - return nil, fmt.Errorf( + return fmt.Errorf( "boarding input amount is lower than min utxo amount:%d", utxoMinAmount, ) } - return &tx, nil -} - -func checkUnrolledVtxoExpiry( - csvExpiresAt, now time.Time, unrolledVtxoMinExpiryMargin time.Duration, -) error { - if csvExpiresAt.Before(now.Add(unrolledVtxoMinExpiryMargin)) { - return fmt.Errorf( - "unrolled vtxo CSV expires too soon (within %s)", unrolledVtxoMinExpiryMargin, - ) - } return nil } diff --git a/internal/core/application/service_test.go b/internal/core/application/service_test.go index f8540ff6f..312a32420 100644 --- a/internal/core/application/service_test.go +++ b/internal/core/application/service_test.go @@ -172,50 +172,6 @@ func TestResolveMinAmounts(t *testing.T) { } } -// TestCheckUnrolledVtxoExpiry verifies the expiry margin gate that decides -// whether an unrolled VTXO's remaining CSV time is long enough to safely -// rejoin a batch. The margin is always a concrete configured value (config -// validation rejects <= 0), so the function just compares csvExpiresAt to -// now + margin. -func TestCheckUnrolledVtxoExpiry(t *testing.T) { - now := parseTime(t, "2023-10-10 12:00:00") - margin := 5 * time.Minute - - tests := []struct { - description string - csvExpiresAt time.Time - expectErr bool - }{ - { - description: "CSV expires after margin", - csvExpiresAt: now.Add(10 * time.Minute), - expectErr: false, - }, - { - description: "CSV expires within margin", - csvExpiresAt: now.Add(2 * time.Minute), - expectErr: true, - }, - { - description: "CSV expires exactly at margin boundary", - csvExpiresAt: now.Add(margin), - expectErr: false, - }, - } - - for _, tc := range tests { - t.Run(tc.description, func(t *testing.T) { - err := checkUnrolledVtxoExpiry(tc.csvExpiresAt, now, margin) - if tc.expectErr { - require.Error(t, err) - require.Contains(t, err.Error(), "unrolled vtxo CSV expires too soon") - } else { - require.NoError(t, err) - } - }) - } -} - func TestValidateOffchainTxOutputs(t *testing.T) { anchor := txutils.AnchorOutput() diff --git a/internal/core/application/utils.go b/internal/core/application/utils.go index 104f0103c..4dbca465c 100644 --- a/internal/core/application/utils.go +++ b/internal/core/application/utils.go @@ -655,3 +655,62 @@ func calculateBoardingInputAmount(ptx *psbt.Packet) uint64 { func isBoardingInput(in psbt.PInput) bool { return in.WitnessUtxo != nil && len(in.TaprootLeafScript) > 0 } + +// targetBlockInterval is the assumed spacing between blocks, used only to +// express a duration-denominated margin in blocks when the exit delay is +// block-typed. It is bitcoin's difficulty target, not a measurement of the +// chain's current rate. +const targetBlockInterval = 10 * time.Minute + +// exitPathAvailable reports whether a vtxo's unilateral exit path is already +// spendable, or will be within margin of becoming so. +// +// For block-typed relative locktimes the answer must be computed in blocks. A +// block-typed delay routed through RelativeLocktime.Seconds() is converted at +// SECONDS_PER_BLOCK = 1 (pkg/ark-lib/locktime.go), which would treat a 144-block +// exit as maturing 144 seconds after confirmation. +// +// Per BIP68, an input confirmed at height H with a relative locktime of N blocks +// first becomes spendable in block H+N, so the exit is available once the next +// block to be mined is at or past that height, i.e. tip+1 >= H+N. margin shifts +// that threshold earlier, so a non-zero margin refuses inputs that are close to +// maturing rather than only those that already have. +// +// margin is a duration because that is what it protects: the wall-clock time a +// batch needs to finalize. Against a block-typed delay it is converted with +// blocksForDuration. +func exitPathAvailable( + confirmedAt, tip *ports.BlockTimestamp, + delay arklib.RelativeLocktime, margin time.Duration, now time.Time, +) (bool, error) { + if delay.Type == arklib.LocktimeTypeBlock { + if tip == nil { + return false, fmt.Errorf("missing chain tip for block-typed exit delay") + } + // Height 0 is the not-found sentinel on the confirmation lookup, never a + // real confirmation height for a boarding utxo. + if confirmedAt == nil || confirmedAt.Height == 0 { + return false, fmt.Errorf("missing confirmation height for block-typed exit delay") + } + nextHeight := int64(tip.Height) + 1 + blocksForDuration(margin) + maturesAt := int64(confirmedAt.Height) + int64(delay.Value) + return nextHeight >= maturesAt, nil + } + + if confirmedAt == nil { + return false, fmt.Errorf("missing confirmation timestamp for exit delay") + } + expiresAt := time.Unix(confirmedAt.Time, 0). + Add(time.Duration(delay.Seconds()) * time.Second) + return expiresAt.Before(now.Add(margin)), nil +} + +// blocksForDuration converts a duration margin into a whole number of blocks, +// rounding up so that any non-zero margin is worth at least one block. Rounding +// down would silently drop the margin for every value below the block interval. +func blocksForDuration(margin time.Duration) int64 { + if margin <= 0 { + return 0 + } + return int64((margin + targetBlockInterval - 1) / targetBlockInterval) +} diff --git a/internal/test/e2e/e2e_test.go b/internal/test/e2e/e2e_test.go index fc611db4c..e8bc7edf4 100644 --- a/internal/test/e2e/e2e_test.go +++ b/internal/test/e2e/e2e_test.go @@ -729,11 +729,16 @@ func TestUnrolledVtxoRejoinBatch(t *testing.T) { } require.NotZero(t, unrolledVtxo.Amount) - // Wait past the unilateral exit delay (regtest: 20 in - // CSV-seconds) so the exit path is open. The server should - // then refuse to accept the unrolled VTXO as a boarding input. - time.Sleep(25 * time.Second) - require.NoError(t, generateBlocks(1)) + // Mine past the unilateral exit delay (regtest: 20 blocks) so the + // exit path is genuinely open. The server should then refuse to + // accept the unrolled VTXO as a boarding input. + // + // This has to be counted in blocks, not slept out. The delay is + // block-typed here (ARKD_VTXO_TREE_EXPIRY=40 is under BIP68's 512 + // threshold), so no amount of wall-clock waiting opens the exit + // path. + require.NoError(t, generateBlocks(20)) + time.Sleep(5 * time.Second) signingClosure, err := offchainAddr.CollaborativeClosure() require.NoError(t, err)