Skip to content

sweepbatcher: add mode with presigned transactions #891

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 8, 2025

Conversation

starius
Copy link
Collaborator

@starius starius commented Feb 27, 2025

In this mode sweepbatcher uses transactions provided by the Presigned helper.
Transactions are signed upon adding an input to a batch.

A single Batcher instance can handle both presigned and regular batches.
Currently presigned and non-presigned sweeps never appear in the same batch.

Pull Request Checklist

  • Update release_notes.md if your PR contains major features, breaking changes or bugfixes

@starius starius self-assigned this Feb 28, 2025
@starius starius marked this pull request as ready for review February 28, 2025 03:55
@starius starius force-pushed the presigned branch 2 times, most recently from 3cbc2e6 to 418bd2d Compare March 4, 2025 17:18
Copy link
Collaborator

@hieblmi hieblmi left a comment

Choose a reason for hiding this comment

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

Amazing work @starius. I've left a couple of questions in my first pass.

for _, s := range b.sweeps {
if !s.presigned {
b.log().Infof("failed to add sweep %x to the "+
"batch, because the batch has "+
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: maybe clarify "failed to add presigned sweep %x..."

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

// Ensure that all the sweeps in the batch don't use presigned.
for _, s := range b.sweeps {
if s.presigned {
b.log().Infof("failed to add sweep %x to the "+
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: maybe clarify "failed to add non-presigned sweep %x..."

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed


for _, sweep := range b.sweeps {
if sweep.presigned {
return true, nil
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think if all sweeps are presigned we return true, false otherwise. So we should return false here if !sweep.presigned, otherwise a presigned batch could still have non-presigned sweeps.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I rewrote the function to ensure that all the sweeps have the same presigned status. If there is a mix, the function returns an error. This would be a bug.

@@ -564,8 +628,40 @@ func (b *Batcher) Run(ctx context.Context) error {
}
}

// PresignSweep creates and stores presigned 1:1 transactions for the sweep.
// This method must be called prior to AddSweep if presigned mode is enabled.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I see this is ensured in the batch by b.ensurePresigned

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I updated ensurePresigned and SignTx interface (added argument presignedOnly), so ensurePresigned actually ensured that it is presigned, not tried to sign (become read-only). Also added a test making sure that the batcher crashes if AddSweep is called before calling PresignSweep (for a presigned sweep). (AddSweep can't return that error, since it only pushes a sweep to the event loop.)

}

// Find the feerate needed to get into next block. Use conf_target=2,
nextBlockFeerate, err := b.wallet.EstimateFeeRate(ctx, 2)
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: camel case nextBlockFeeRate

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

return false, nil
}
} else {
if err := b.ensurePresigned(ctx, sweep); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

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

if err == nil here, don't we then have to call b.presign for the sweep here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If ensurePresigned returned no error, this means, it has been presigned already. No need to presign again. If not presigned, then crash the batcher (this is only possible because of a bug).

// use the same mode (presigned or regular).
if sweep.presigned {
// Ensure that all the sweeps in the batch use presigned mode.
for _, s := range b.sweeps {
Copy link
Collaborator

Choose a reason for hiding this comment

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

instead of iterating, do you think it makes sense to add a presigned flag to the batch struct that we can check before adding a sweep to a batch?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I plan to allow half-presigned batches in the future to use a regular sweep an an anchor and such a flag would be removed. So I decided to keep this calculated not to forget to remove it in the future :)

batchAmt += sweep.value
}

// Find when at least one sweep expires.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: "find the sweep with the earliest expiry"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

}

// Find the feerate needed to get into next block. Use conf_target=2,
nextBlockFeerate, err := b.wallet.EstimateFeeRate(ctx, 2)
Copy link
Collaborator

Choose a reason for hiding this comment

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

why is it 2? Maybe create a constant?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I updated this place:

        // priorityConfTarget defines the confirmation target for quick
        // inclusion in a block. A value of 2, rather than 1, is used to prevent
        // overestimation caused by temporary anomalies, such as periods without
        // blocks.
        const priorityConfTarget = 2

        // Find the feerate needed to get into next block.
        nextBlockFeeRate, err := b.wallet.EstimateFeeRate(
                ctx, priorityConfTarget,
        )

This fee rate is only used to define a high fee rate (10x of this value and at least 100 sats/vbyte).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

BTW the fee estimator fails with conf_target=1: #898
Maybe it worth making this a global constant in Loop and use it in all such places.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated the comment:

        // priorityConfTarget defines the confirmation target for quick
        // inclusion in a block. A value of 2, rather than 1, is used to prevent
        // fee estimator from failing.
        // See https://github.com/lightninglabs/loop/issues/898
        const priorityConfTarget = 2

@starius starius requested a review from hieblmi March 6, 2025 14:15
@starius starius force-pushed the presigned branch 4 times, most recently from 613903a to b115f41 Compare March 18, 2025 18:19
@starius starius force-pushed the presigned branch 6 times, most recently from 6abbc68 to 0006a7e Compare April 6, 2025 03:38
@lightninglabs-deploy
Copy link

@hieblmi: review reminder

Copy link
Collaborator

@hieblmi hieblmi left a comment

Choose a reason for hiding this comment

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

It's great work @starius, I've left a couple of more comments, looks almost good to me.


// DestPkScript returns destination pkScript used in a presigned
// transaction sweeping the inputs. Returns an error, if such tx
// doesn't exist. If there are many such transactions, returns any
Copy link
Collaborator

Choose a reason for hiding this comment

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

its not possible that if there are many such transactions, that they'd have different pkScripts, I assume.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, all of the transactions should have the same destination address, by construction. However this is not enforced by PresignedHelper. In theory someone can call Presign on transactions with the same list of inputs and different destination addresses and all of the addresses would be considered valid. We can demand PresignedHelper to check that address is always the same, but this would make queries heavier. (Checking in Presign that destination address is the same in all presigned transactions with such inputs.)

return false, nil
}

// If this is new batch being formed, make sure we already have
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: if this is a

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

@@ -1960,7 +2116,21 @@ func (b *batch) persist(ctx context.Context) error {

// getBatchDestAddr returns the batch's destination address. If the batch
// has already generated an address then the same one will be returned.
// The method must not be used in presigned mode. Use getSweepsDestAddr instead.
Copy link
Collaborator

Choose a reason for hiding this comment

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

should we call this method in the comment getPresignedSweepsDestAddr to clarify?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Renamed. Thanks!


// getSweepsDestAddr returns the destination address used by a group of sweeps.
// The method must be used in presigned mode only.
func (b *batch) getSweepsDestAddr(ctx context.Context,
Copy link
Collaborator

Choose a reason for hiding this comment

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

could call it getPresignedSweepsDestAddr to make it clearer that this only makes sense in the context of presigned batches.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Renamed

return fmt.Errorf("presignedHelper is not installed")
}
if len(b.sweeps) != 0 {
return fmt.Errorf("ensurePresigned is done when adding to an " +
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: shoud be done...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

for _, sweep := range newSweeps {
batchAmt += sweep.value
}
const presignedOnly = true
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe we could call this flag loadOnly or something similar?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Renamed to loadOnly.

)

// ensurePresigned checks that we can sign a 1:1 transaction sweeping the input.
func (b *batch) ensurePresigned(ctx context.Context, newSweeps []*sweep) error {
Copy link
Collaborator

Choose a reason for hiding this comment

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

does ensurePresigned imply that there should be already be a transaction with given inputs that has a populated witness? I don't see a check anywhere in this method or sub-method that checks for a witness.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added a check to CheckSignedTx which is called by this method:

// Make sure all inputs of signedTx have a non-empty witness.
for _, txIn := range signedTx.TxIn {
	if len(txIn.Witness) == 0 {
		return fmt.Errorf("input %s of signed tx is not signed",
			txIn.PreviousOutPoint)
	}
}

}

// Try to presign this transaction.
err = presigner.Presign(ctx, tx, batchAmt)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just a side question: Currently the result is not yet used. Is it intended to then pass this pre-signed transaction into our store? I guess once the batch is confirmed we could clean up all the presigned fee versions.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, would Presigner handle persisting the txns? Otherwise wouldn't that require another client change?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

PresignHelper is responsible for storing the transactions. SweepBatcher relies on helper.SignTx to be able to load transactions stored by previous helper.Presign and helper.SignTx calls.

I guess once the batch is confirmed we could clean up all the presigned fee versions.

This is done. There is the method CleanupTransactions which is called by batch.handleConf method.

ctx, tx, batchAmt, minRelayFee, feeRate, presignedOnly,
)
if err != nil {
return 0, fmt.Errorf("failed to sign tx: %w", err),
Copy link
Collaborator

Choose a reason for hiding this comment

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

is it trying to sign when presignedOnly is passed in?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

presignedOnly = false in this call, so it tries to sign interactively and if it fails, goes to the store of presigned transactions.

}

// CheckSignedTx makes sure that signedTx matches the unsignedTx. It checks
// according to criteria specified in the description of PresignedHelper.SignTx.
Copy link
Collaborator

Choose a reason for hiding this comment

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

looks like the input sequence numbers aren't checked as described in the godoc of SignTx.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sequence numbers are compared using unsignedMap. Also there is a test case to verify this.

@starius starius requested a review from hieblmi April 15, 2025 14:41
@levmi levmi removed the request for review from sputn1ck April 15, 2025 15:26
@starius starius marked this pull request as draft April 16, 2025 18:00
@starius
Copy link
Collaborator Author

starius commented Apr 16, 2025

Found a couple of edge cases. Converting to a draft until the fix is ready.

@starius starius force-pushed the presigned branch 2 times, most recently from 55dfa9f to 5c68867 Compare April 29, 2025 17:12
Copy link
Collaborator

@hieblmi hieblmi left a comment

Choose a reason for hiding this comment

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

Left some comments and questions for the latest set of changes.

@starius
Copy link
Collaborator Author

starius commented May 1, 2025

@hieblmi Thanks for comments! 🎉
I fixed them.

Also found a small edge case for function constructUnsignedTx. Fixed it in commit sweepbatcher: fix rounding in constructUnsignedTx.

@starius starius requested a review from hieblmi May 1, 2025 04:24
@starius starius force-pushed the presigned branch 2 times, most recently from 930d193 to 065ff70 Compare May 2, 2025 01:50
@starius
Copy link
Collaborator Author

starius commented May 2, 2025

Updated the last commit sweepbatcher: add mode with presigned transactions. Skipped ensurePresigned check in AddSweep if the sweep is fully confirmed (the presigned transaction is cleaned up by this time).

Copy link
Collaborator

@hieblmi hieblmi left a comment

Choose a reason for hiding this comment

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

Really amazing work on this @starius. LGTM!

@starius
Copy link
Collaborator Author

starius commented May 5, 2025

@hieblmi I expanded the purging test to verify that if the sweeps are online at the time of purging (i.e. confirmed tx doesn't have them and they have to be re-added to the batcher) they can be successfully added to an existing batch.

I did it in a temp commit to make it easier to re-review the change.

@starius
Copy link
Collaborator Author

starius commented May 5, 2025

@hieblmi One more change. I replaces helper.IsPresigned() method with a field in SweepInfo. This makes things easier on naut side. I pushed it in a separate temp commit.

Copy link
Member

@bhandras bhandras left a comment

Choose a reason for hiding this comment

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

Looks really good! My only complaint is the possible timeout overflow, otherwise LGTM!

)

// Calculate the locktime value to use for high feerate transactions.
highFeeRateLockTime := uint32(timeout - timeoutThreshold)
Copy link
Member

Choose a reason for hiding this comment

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

This can overflow if timeout is less than 50.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I changed this to fix this edge case. Thanks! Added a test case.

// Calculate the locktime value to use for high feerate transactions.
// If timeout <= timeoutThreshold, don't set LockTime (keep value 0).
var highFeeRateLockTime uint32
if timeout > timeoutThreshold {
	highFeeRateLockTime = uint32(timeout - timeoutThreshold)
}

@starius
Copy link
Collaborator Author

starius commented May 8, 2025

@bhandras I pushed two commits to squash them later. They have the fixes for your comments.
Please take another look!

@bhandras bhandras self-requested a review May 8, 2025 20:07
Copy link
Member

@bhandras bhandras left a comment

Choose a reason for hiding this comment

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

Really nice additions @starius 🥇 LGTM! 🎉

starius added 10 commits May 8, 2025 17:24
Previously it may round one satoshi towards zero resulting in fee-rate
slightly lower than requested.
This is needed to run additional checks in AddSweep in the next commit.
In this mode sweepbatcher uses transactions provided by the Presigned helper.
Transactions are signed upon adding an input to a batch.

A single Batcher instance can handle both presigned and regular batches.
Currently presigned and non-presigned sweeps never appear in the same batch.
@starius starius merged commit 125d83f into lightninglabs:master May 8, 2025
4 checks passed
@starius starius deleted the presigned branch May 8, 2025 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants