Skip to content
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

Multisig wallet features, BCUR, native tpub support, and more #41

Merged
merged 24 commits into from
Apr 16, 2021

Conversation

mflaxman
Copy link
Collaborator

This is a big branch that I just keep adding to for months and it's getting out of hand. It might make sense to merge it in now just as a checkpoint (would squash down of course).

@mflaxman
Copy link
Collaborator Author

@jimmysong any thoughts on this? I'd like to merge it.

Copy link
Collaborator

@jimmysong jimmysong left a comment

Choose a reason for hiding this comment

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

Generally pretty good, mostly nits, except for the bcur stuff. That feels like it should be an object. I don't know much about it, but would be interested in your thoughts.

buidl/bcur.py Outdated
cbor = bc32decode(data)
if checksum is not None:
h = bc32decode(checksum)
assert h == hashlib.sha256(cbor).digest()
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe raise a helpful error about the checksum being wrong?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good call (here and elsewhere when I was returning error messages)

buidl/bcur.py Outdated
y_int = int(xofy_parts[1])

if x_int > y_int:
return None, None, None, None, "x must be >= y (in xOFy)"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why are you returning such a large number of things here? I would rather have it raise an exception or return an object if it makes sense. Same for other return statements here

import hashlib


def bcur_encode(data):
Copy link
Collaborator

Choose a reason for hiding this comment

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

This whole file feels like it should be an object. The number of elements being passed back and forth seem messy to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You're right, it's messy.

I was trying to avoid extra classes, but it's ugly. Will rework.

buidl/helper.py Outdated
try:
int(int_as_string)
return True
except Exception:
Copy link
Collaborator

Choose a reason for hiding this comment

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

except ValueError, Exception is too broad

buidl/psbt.py Outdated

self.validate()

TX_FEE_SATS = self.tx_obj.fee()
Copy link
Collaborator

Choose a reason for hiding this comment

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

why caps?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This code block is so complicated, and my thinking is that by marking it as all caps I'll know that it was treated as a constant (not edited). Perhaps that's a bad convention?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I usually reserve all-caps for global constants. What we have here is something that doesn't change within the function, which to me should be a normal variable. There may come some change in the future where the fee may need to be modified based on what condition from the taproot MAST tree gets executed, so I would change this to be a variable.

),
}

if psbt_out.named_pubs:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can see a case where you're naming multiple outputs, one which goes to one of the pubkeys of the quorum (maybe paying out a bet). I'm not sure this is a safe assumption.

Copy link
Collaborator Author

@mflaxman mflaxman Apr 13, 2021

Choose a reason for hiding this comment

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

Can you describe this a little more?

I want developers to be able to rely on battle-tested logic for building apps on top of. I'm trying to keep this simple, but it's a little unwieldy.

I'd like to also add support for batching (multiple outputs), but am concerned the code would get very hard to read.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You're assuming here that if the output is a bunch of named public keys (that is, known to the signers) that it's a change output. This may not be the case. If, for example you have a 2-of-2 output being spent where some amount of it goes back to the 2-of-2 but the rest goes to one of the parties (say in a bet that pays out multiple times depending on the conditions), then the spending output will be caught as a change output according to this logic. Does that make sense?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, I think I might be misunderstanding what you're saying :(

In order to be counted as change, the current code requires that the output shares the same xfps and m-of-n as every input*:

buidl-python/buidl/psbt.py

Lines 635 to 656 in 55e09f5

if psbt_out.named_pubs:
# Confirm below that this is correct (throw error otherwise)
output_desc["is_change"] = True
root_xfp_hexes = [] # for calculating msig fingerprint
for _, details in psbt_out.named_pubs.items():
root_xfp_hexes.append(details.root_fingerprint.hex())
quorum_m, quorum_n = psbt_out.witness_script.get_quorum()
if quorum_n != len(root_xfp_hexes):
raise SuspiciousTransaction(
f"Transaction claims {quorum_m}-of-{quorum_n} but has {len(root_xfp_hexes)} root fingerprints, which != {quorum_n}"
)
output_msig_id = calc_multisig_id(
quorum_m=quorum_m, root_xfp_hexes=root_xfp_hexes
)
if output_msig_id != inputs_desc[0]["msig_id"]:
# ALL inputs have the same msig_digest
raise SuspiciousTransaction(
f"Output #{cnt} is claiming to be change but has different multisig wallet(s)! Do a sweep transaction (1-output) if you want this wallet to cosign."
)

Shifting from an input controlled by the same group to a new output controlled by that same group does indeed sound like change to me (and the behavior that an end-user manually verifying would expect).

What am I missing? I don't want to introduce a vulnerability here!

* I am a little nervous relying on the PSBT class, but the logic in validate() seems solid as far as confirming that the xpubs in the PSBT correspond to the ones being used in the actual transactions. However, the xfp hex is the only part that can't be verified (it would be trivial to swap this out). I think in my calc_multisig_id method I should be using the xpubs (which are validated by the PSBT class) and not the xfps (which can only be validated by each Signer who holds the corresponding xpriv)!

buidl/script.py Outdated
@@ -428,3 +428,22 @@ def p2sh_address(self, testnet=False):
redeem_script = self.script_pubkey().redeem_script()
# return the p2sh address of the RedeemScript (remember testnet)
return redeem_script.address(testnet)

def is_p2wsh(self):
Copy link
Collaborator

Choose a reason for hiding this comment

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

shouldn't this be is_p2wsh_multisig?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch.

buidl/script.py Outdated
Return the m-of-n of this multisig, as in 2-of-3 or 3-of-5
"""

assert self.is_p2wsh(), f"Not a multisig witness script: {self}"
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would prefer a specific Exception not a bald assert.

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 it to this locally:

        if not self.is_p2wsh_multisig():
            raise RuntimeError(f"Not a multisig witness script: {self}")

Is that what you had in mind? I could define an Exception class just for catching this error, but I didn't because I thought it too much clutter.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe ValueError or SyntaxError? Those are built in and seem more appropriate here.

@mflaxman
Copy link
Collaborator Author

@jimmysong thanks for the code review!

I reworked bcur.py into two new objects BCURSingle and BCURMulti (single and multi BCUR's have some unique properties, my original attempt to shoehorn them together was too convoluted).

Look good?

To me, the one big outstanding issue is any potential security vulnerabilities in describe_basic_multisig_tx(). In retrospect, it would've been better to make this a separate PR. But, it's close and I'd really like to have that in the library.

Copy link
Collaborator

@jimmysong jimmysong left a comment

Choose a reason for hiding this comment

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

I like the refactor of BCUR much better. I would like to see the exceptions renamed and the one logic issue with the change output addressed, but otherwise looks good.

buidl/bcur.py Outdated
# BCUR Exceptions


class INCONSISTENT_BCUR_STRING(RuntimeError):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not a huge fan of all-caps exceptions. Generally, I've seen camel-cased exceptions ending with "Error" or "Exception" so you would have InconsistentStringError (or ValueError?), InvalidChecksumError, InvalidEncodingError (SyntaxError?) and InvalidStringError (or SyntaxError?). You can use some built in ones if they make sense, but I would like to see the classes follow naming conventions of the rest of Python.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oof! clearly I haven't been writing a lot of python lately. Good catch.

@jimmysong
Copy link
Collaborator

LGTM, merge when you want.

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.

2 participants