-
Notifications
You must be signed in to change notification settings - Fork 27
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
Conversation
@jimmysong any thoughts on this? I'd like to merge it. |
There was a problem hiding this 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() |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)" |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why caps?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 xfp
s and m-of-n
as every input*
:
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): |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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}" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
@jimmysong thanks for the code review! I reworked Look good? To me, the one big outstanding issue is any potential security vulnerabilities in |
There was a problem hiding this 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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
LGTM, merge when you want. |
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).