diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 4ca5c50c05..13e4dbd09c 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -40,6 +40,7 @@ import ( "decred.org/dcrwallet/v5/wallet" _ "decred.org/dcrwallet/v5/wallet/drivers/bdb" "github.com/btcsuite/btcd/btcec/v2" + "github.com/davecgh/go-spew/spew" "github.com/decred/dcrd/blockchain/stake/v5" blockchain "github.com/decred/dcrd/blockchain/standalone/v2" "github.com/decred/dcrd/chaincfg/chainhash" @@ -48,6 +49,7 @@ import ( "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" "github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr" + "github.com/decred/dcrd/dcrjson/v4" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/hdkeychain/v3" chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v4" @@ -109,6 +111,10 @@ const ( ticketSize = dexdcr.MsgTxOverhead + dexdcr.P2PKHInputSize + 2*dexdcr.P2SHOutputSize /* stakesubmission and sstxchanges */ + 32 /* see e.g. RewardCommitmentScript */ minVSPTicketPurchaseSize = dexdcr.MsgTxOverhead + dexdcr.P2PKHInputSize + dexdcr.P2PKHOutputSize + ticketSize + + // sstxCommitmentString is the string to insert when a verbose + // transaction output's pkscript type is a ticket commitment. + sstxCommitmentString = "sstxcommitment" ) var ( @@ -717,6 +723,7 @@ var _ asset.NewAddresser = (*ExchangeWallet)(nil) var _ asset.PrivateSwapper = (*ExchangeWallet)(nil) var _ asset.GeocodeRedeemer = (*ExchangeWallet)(nil) var _ asset.TxAbandoner = (*ExchangeWallet)(nil) +var _ asset.Multisigner = (*ExchangeWallet)(nil) type block struct { height int64 @@ -8148,3 +8155,525 @@ func getDcrdataTxs(ctx context.Context, addr string, net dex.Network) (txs []*wi return } + +// SendFundsToMultisig sends amounts to a multisig address, then creates a +// transaction that spends those funds. ErrMultisigPartialSend and partial +// results are returned if an error happens after sending funds. +func (dcr *ExchangeWallet) SendFundsToMultisig(ctx context.Context, pm *asset.PaymentMultisig) (pmTx *asset.PaymentMultisigTx, err error) { + xpubs := make([]*secp256k1.PublicKey, len(pm.SignerXpubs)) + for i, b := range pm.SignerXpubs { + key, err := secp256k1.ParsePubKey(b) + if err != nil { + return nil, err + } + xpubs[i] = key + } + + addrToValue := make(map[stdaddr.Address]uint64) + var value uint64 + for addrStr, val := range pm.AddrToVal { + amt, err := dcrutil.NewAmount(val) + if err != nil { + return nil, err + } + addr, err := stdaddr.DecodeAddress(addrStr, dcr.chainParams) + if err != nil { + return nil, err + } + addrToValue[addr] = uint64(amt) + value += uint64(amt) + } + + feeRate := dcr.targetFeeRateWithFallback(2, 0) + redeemTxSize := dexdcr.PaymentMultisigRedeemTxSize(int64(len(xpubs)), pm.NRequired, int64(len(addrToValue))) + redeemFee := feeRate * redeemTxSize + value += redeemFee + + bal, err := dcr.balance() + if err != nil { + return nil, err + } + + // Not adding fee for initial send but sending will fail anyway in that + // case. + if bal.Available < value { + return nil, errors.New("not enough funds to send to multisig") + } + + locktime := time.Unix(pm.Locktime, 0).UTC() + // TODO: Remove this 3 day check. + if locktime.After(time.Now().Add(time.Hour * 72)) { + return nil, errors.New("locktime more than three days in the future, make it shorter") + } + + addr, err := dcr.wallet.InternalAddress(dcr.ctx, dcr.depositAccount()) + if err != nil { + return nil, err + } + + redeemScript, err := dexdcr.MakePaymentMultisig(addr.String(), xpubs, pm.NRequired, pm.Locktime, dcr.chainParams) + if err != nil { + return nil, fmt.Errorf("unable to make multisig redeem script: %v", err) + } + dcr.log.Infof("Multisig redeem script: %x", redeemScript) + + scriptAddr, err := stdaddr.NewAddressScriptHashV0(redeemScript, dcr.chainParams) + if err != nil { + return nil, fmt.Errorf("error encoding script address: %w", err) + } + + // Funds are sent. Point of no return. + msgTx, _, _, err := dcr.sendToAddress(scriptAddr, value, feeRate) + if err != nil { + return nil, err + } + + // Add a data push value to the beginning of the script so that we can + // parse in the same way as a script with signatures. + redeemScriptWithDataPush, err := txscript.NewScriptBuilder().AddData(redeemScript).Script() + if err != nil { + return nil, err + } + + // Create the transaction that spends the contract. + redeemTx := wire.NewMsgTx() + redeemTx.LockTime = uint32(pm.Locktime) + // TODO: Change is always last output? + hash := msgTx.TxHash() + prevOut := wire.NewOutPoint(&hash, 0, wire.TxTreeRegular) + txIn := wire.NewTxIn(prevOut, int64(value), redeemScriptWithDataPush) + // Enable the OP_CHECKLOCKTIMEVERIFY opcode to be used. + // + // https://github.com/decred/dcrd/blob/8f5270b707daaa1ecf24a1ba02b3ff8a762674d3/txscript/opcode.go#L981-L998 + txIn.Sequence = wire.MaxTxInSequenceNum - 1 + redeemTx.AddTxIn(txIn) + + for addr, amt := range addrToValue { + pkScriptVer, pkScript := addr.PaymentScript() + txOut := newTxOut(int64(amt), pkScriptVer, pkScript) + redeemTx.AddTxOut(txOut) + } + + hasSigs := make([]bool, len(xpubs)) + b, err := redeemTx.Bytes() + if err != nil { + return &asset.PaymentMultisigTx{ + TxHex: spew.Sdump(redeemTx), + HasSigs: hasSigs, + }, asset.ErrMultisigPartialSend + } + + return &asset.PaymentMultisigTx{ + TxHex: hex.EncodeToString(b), + HasSigs: hasSigs, + }, nil +} + +func insert(a [][]byte, index int, value []byte) [][]byte { + if len(a) == index { // nil or empty slice or after last element + return append(a, value) + } + a = append(a[:index+1], a[index:]...) // index < len(a) + a[index] = value + return a +} + +// SignMultisig signs the pmTx with the supplied privateKey and inserts the +// signature at idx among the other signatures. +func (dcr *ExchangeWallet) SignMultisig(ctx context.Context, pmTx *asset.PaymentMultisigTx, privKey []byte) (*asset.PaymentMultisigTx, error) { + // Decode the spending tx and extract values from it. + txB, err := hex.DecodeString(pmTx.TxHex) + if err != nil { + return nil, err + } + msgTx := new(wire.MsgTx) + if err := msgTx.FromBytes(txB); err != nil { + return nil, err + } + if len(msgTx.TxIn) < 1 { + return nil, errors.New("spending tx does not have an input") + } + + redeemScript, sigs, err := dexdcr.SigsFromPaymentMultisig(msgTx.TxIn[0].SignatureScript) + if err != nil { + return nil, err + } + + nRequired, _, _, pubKeys, err := dexdcr.ExtractPaymentMultisigDetails(redeemScript) + if err != nil { + return nil, err + } + + if len(pmTx.HasSigs) != len(pubKeys) { + return nil, errors.New("has sigs and number of xpubs different") + } + + if nRequired <= int64(len(sigs)) { + return nil, errors.New("already has enough signatures to send") + } + + // Find our signature's placement. + priv := secp256k1.PrivKeyFromBytes(privKey) + defer priv.Zero() + pubKeyB := priv.PubKey().SerializeCompressed() + sigIdx := -1 + sigOffset := 0 + for i, b := range pubKeys { + if pmTx.HasSigs[i] { + sigOffset += 1 + } + if bytes.Equal(pubKeyB, b.SerializeCompressed()) { + sigIdx = i + break + } + } + if sigIdx < 0 { + return nil, fmt.Errorf("signing pubkey %x for private key not found in signer xpubs", pubKeyB) + } + if sigOffset > len(sigs) { + return nil, fmt.Errorf("not as many sigs found as expected") + } + if pmTx.HasSigs[sigIdx] { + return nil, fmt.Errorf("already have a signature for pubkey %x", pubKeyB) + } + // Create our signature. + sig, err := sign.RawTxInSignature(msgTx, 0, redeemScript, txscript.SigHashAll, privKey, dcrec.STEcdsaSecp256k1) + if err != nil { + return nil, err + } + // Add our signature to the others and repace the sig script. + sigScript, err := dexdcr.RedeemPaymentMultisig(redeemScript, insert(sigs, sigOffset, sig)) + if err != nil { + return nil, err + } + msgTx.TxIn[0].SignatureScript = sigScript + b, err := msgTx.Bytes() + if err != nil { + return nil, err + } + + pmTx.HasSigs[sigIdx] = true + return &asset.PaymentMultisigTx{ + TxHex: hex.EncodeToString(b), + HasSigs: pmTx.HasSigs, + }, nil +} + +// RefundMultisig refunds a multisig if it is after the locktime and we are the +// sender. +func (dcr *ExchangeWallet) RefundMultisig(ctx context.Context, pmTx *asset.PaymentMultisigTx) (string, error) { + // Decode the spending tx and extract values from it. + txB, err := hex.DecodeString(pmTx.TxHex) + if err != nil { + return "", err + } + msgTx := new(wire.MsgTx) + if err := msgTx.FromBytes(txB); err != nil { + return "", err + } + if len(msgTx.TxIn) < 1 { + return "", errors.New("spending tx does not have an input") + } + + redeemScript, _, err := dexdcr.SigsFromPaymentMultisig(msgTx.TxIn[0].SignatureScript) + if err != nil { + return "", err + } + + _, sender, locktime, pubKeys, err := dexdcr.ExtractPaymentMultisigDetails(redeemScript) + if err != nil { + return "", err + } + + expired, err := dcr.LockTimeExpired(dcr.ctx, time.Unix(locktime, 0)) + if err != nil { + return "", err + } + if !expired { + return "", fmt.Errorf("locktime not yet expired. Expires at %v", time.Unix(locktime, 0)) + } + + addr, err := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(sender[:], dcr.chainParams) + if err != nil { + return "", fmt.Errorf("error generating address: %v", err) + } + + size := dexdcr.PaymentMultisigRefundTxSize(int64(len(pubKeys))) + feeRate := dcr.targetFeeRateWithFallback(2, 0) + refundFee := int64(feeRate) * int64(size) + pkScriptVer, pkScript := addr.PaymentScript() + txOut := newTxOut(msgTx.TxIn[0].ValueIn-refundFee, pkScriptVer, pkScript) + msgTx.TxOut = []*wire.TxOut{txOut} + msgTx.TxOut[0].Value -= refundFee + + // Create our signature. + redeemSig, pubkey, err := dcr.createSig(msgTx, 0, redeemScript, addr) + if err != nil { + return "", err + } + sigScript, err := dexdcr.RefundPaymentMultisig(redeemScript, pubkey, redeemSig) + if err != nil { + return "", err + } + msgTx.TxIn[0].SignatureScript = sigScript + + txHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, msgTx, true) + if err != nil { + return "", err + } + return txHash.String(), nil +} + +// createVinList returns a slice of JSON objects for the inputs of the passed +// transaction. +func createVinList(mtx *wire.MsgTx, isTreasuryEnabled bool) []chainjson.Vin { + // Treasurybase transactions only have a single txin by definition. + // + // NOTE: This check MUST come before the coinbase check because a + // treasurybase will be identified as a coinbase as well. + vinList := make([]chainjson.Vin, len(mtx.TxIn)) + if isTreasuryEnabled && blockchain.IsTreasuryBase(mtx) { + txIn := mtx.TxIn[0] + vinEntry := &vinList[0] + vinEntry.Treasurybase = true + vinEntry.Sequence = txIn.Sequence + vinEntry.AmountIn = dcrutil.Amount(txIn.ValueIn).ToCoin() + vinEntry.BlockHeight = txIn.BlockHeight + vinEntry.BlockIndex = txIn.BlockIndex + return vinList + } + + // Coinbase transactions only have a single txin by definition. + if blockchain.IsCoinBaseTx(mtx, isTreasuryEnabled) { + txIn := mtx.TxIn[0] + vinEntry := &vinList[0] + vinEntry.Coinbase = hex.EncodeToString(txIn.SignatureScript) + vinEntry.Sequence = txIn.Sequence + vinEntry.AmountIn = dcrutil.Amount(txIn.ValueIn).ToCoin() + vinEntry.BlockHeight = txIn.BlockHeight + vinEntry.BlockIndex = txIn.BlockIndex + return vinList + } + + // Treasury spend transactions only have a single txin by definition. + if isTreasuryEnabled && stake.IsTSpend(mtx) { + txIn := mtx.TxIn[0] + vinEntry := &vinList[0] + vinEntry.TreasurySpend = hex.EncodeToString(txIn.SignatureScript) + vinEntry.Sequence = txIn.Sequence + vinEntry.AmountIn = dcrutil.Amount(txIn.ValueIn).ToCoin() + vinEntry.BlockHeight = txIn.BlockHeight + vinEntry.BlockIndex = txIn.BlockIndex + return vinList + } + + // Stakebase transactions (votes) have two inputs: a null stake base + // followed by an input consuming a ticket's stakesubmission. + isSSGen := stake.IsSSGen(mtx) + + for i, txIn := range mtx.TxIn { + // Handle only the null input of a stakebase differently. + if isSSGen && i == 0 { + vinEntry := &vinList[0] + vinEntry.Stakebase = hex.EncodeToString(txIn.SignatureScript) + vinEntry.Sequence = txIn.Sequence + vinEntry.AmountIn = dcrutil.Amount(txIn.ValueIn).ToCoin() + vinEntry.BlockHeight = txIn.BlockHeight + vinEntry.BlockIndex = txIn.BlockIndex + continue + } + + // The disassembled string will contain [error] inline + // if the script doesn't fully parse, so ignore the + // error here. + disbuf, _ := txscript.DisasmString(txIn.SignatureScript) + + vinEntry := &vinList[i] + vinEntry.Txid = txIn.PreviousOutPoint.Hash.String() + vinEntry.Vout = txIn.PreviousOutPoint.Index + vinEntry.Tree = txIn.PreviousOutPoint.Tree + vinEntry.Sequence = txIn.Sequence + vinEntry.AmountIn = dcrutil.Amount(txIn.ValueIn).ToCoin() + vinEntry.BlockHeight = txIn.BlockHeight + vinEntry.BlockIndex = txIn.BlockIndex + vinEntry.ScriptSig = &chainjson.ScriptSig{ + Asm: disbuf, + Hex: hex.EncodeToString(txIn.SignatureScript), + } + } + + return vinList +} + +// createVoutList returns a slice of JSON objects for the outputs of the passed +// transaction. +func createVoutList(mtx *wire.MsgTx, chainParams *chaincfg.Params) ([]chainjson.Vout, error) { + + txType := stake.DetermineTxType(mtx) + voutList := make([]chainjson.Vout, 0, len(mtx.TxOut)) + for i, v := range mtx.TxOut { + // The disassembled string will contain [error] inline if the + // script doesn't fully parse, so ignore the error here. + disbuf, _ := txscript.DisasmString(v.PkScript) + + // Attempt to extract addresses from the public key script. In + // the case of stake submission transactions, the odd outputs + // contain a commitment address, so detect that case + // accordingly. + var addrs []stdaddr.Address + var scriptType string + var reqSigs uint16 + var commitAmt *dcrutil.Amount + if txType == stake.TxTypeSStx && (i%2 != 0) { + scriptType = sstxCommitmentString + addr, err := stake.AddrFromSStxPkScrCommitment(v.PkScript, + chainParams) + if err != nil { + return nil, fmt.Errorf("failed to decode ticket "+ + "commitment addr output for tx hash "+ + "%v, output idx %v", mtx.TxHash(), i) + } else { + addrs = []stdaddr.Address{addr} + } + amt, err := stake.AmountFromSStxPkScrCommitment(v.PkScript) + if err != nil { + return nil, fmt.Errorf("failed to decode ticket "+ + "commitment amt output for tx hash %v"+ + ", output idx %v", mtx.TxHash(), i) + } else { + commitAmt = &amt + } + } else { + // Attempt to extract known addresses associated with the script. + var st stdscript.ScriptType + st, addrs = stdscript.ExtractAddrs(v.Version, v.PkScript, chainParams) + scriptType = st.String() + + // Determine the number of required signatures for known standard + // dcrdtypes. + reqSigs = stdscript.DetermineRequiredSigs(v.Version, v.PkScript) + } + + encodedAddrs := make([]string, len(addrs)) + for j, addr := range addrs { + encodedAddr := addr.String() + encodedAddrs[j] = encodedAddr + } + + var vout chainjson.Vout + voutSPK := &vout.ScriptPubKey + vout.N = uint32(i) + vout.Value = dcrutil.Amount(v.Value).ToCoin() + vout.Version = v.Version + voutSPK.Addresses = encodedAddrs + voutSPK.Asm = disbuf + voutSPK.Hex = hex.EncodeToString(v.PkScript) + voutSPK.Type = scriptType + voutSPK.ReqSigs = int32(reqSigs) + if commitAmt != nil { + voutSPK.CommitAmt = dcrjson.Float64(commitAmt.ToCoin()) + } + voutSPK.Version = v.Version + + voutList = append(voutList, vout) + } + + return voutList, nil +} + +// decodeTx decodes a transaction from its hex. +func (dcr *ExchangeWallet) decodeTx(hexStr string) (*chainjson.TxRawDecodeResult, error) { + if len(hexStr)%2 != 0 { + hexStr = "0" + hexStr + } + serializedTx, err := hex.DecodeString(hexStr) + if err != nil { + return nil, err + } + var mtx wire.MsgTx + err = mtx.Deserialize(bytes.NewReader(serializedTx)) + if err != nil { + return nil, err + } + + voutList, err := createVoutList(&mtx, dcr.chainParams) + if err != nil { + return nil, err + } + + isTreasuryEnabled := true + + // Create and return the result. + return &chainjson.TxRawDecodeResult{ + Txid: mtx.TxHash().String(), + Version: int32(mtx.Version), + Locktime: mtx.LockTime, + Expiry: mtx.Expiry, + Vin: createVinList(&mtx, isTreasuryEnabled), + Vout: voutList, + }, nil +} + +type ViewPM struct { + Tx *chainjson.TxRawDecodeResult `json:"tx"` + Locktime time.Time `json:"locktime"` + Pubkeys []string `json:"pubkeys"` + NSigs int `json:"nsigs"` + NRequired int64 `json:"nrequired"` + Sender string `json:"sender"` +} + +// ViewPaymentMultisig returns a tx hex in human readable json format. +func (dcr *ExchangeWallet) ViewPaymentMultisig(pmTx *asset.PaymentMultisigTx) (string, error) { + txB, err := hex.DecodeString(pmTx.TxHex) + if err != nil { + return "", err + } + msgTx := new(wire.MsgTx) + if err := msgTx.FromBytes(txB); err != nil { + return "", err + } + if len(msgTx.TxIn) < 1 { + return "", errors.New("spending tx does not have an input") + } + + redeemScript, sigs, err := dexdcr.SigsFromPaymentMultisig(msgTx.TxIn[0].SignatureScript) + if err != nil { + return "", err + } + + nRequired, sender, locktime, pubKeys, err := dexdcr.ExtractPaymentMultisigDetails(redeemScript) + if err != nil { + return "", err + } + + pks := make([]string, len(pubKeys)) + for i, pk := range pubKeys { + pks[i] = hex.EncodeToString(pk.SerializeCompressed()) + } + + addr, err := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(sender[:], dcr.chainParams) + if err != nil { + return "", fmt.Errorf("error generating address: %v", err) + } + + dTx, err := dcr.decodeTx(pmTx.TxHex) + if err != nil { + return "", err + } + + viewPm := ViewPM{ + Tx: dTx, + Locktime: time.Unix(locktime, 0), + Pubkeys: pks, + NSigs: len(sigs), + NRequired: nRequired, + Sender: addr.String(), + } + + b, err := json.MarshalIndent(viewPm, "", "\t") + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/client/asset/interface.go b/client/asset/interface.go index e9416ff23b..a2fde436e7 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -257,6 +257,10 @@ const ( // too small to cover the gas fees when using a bundler. ErrBundlerRedemptionLotSizeTooSmall = dex.ErrorKind("bundler redemption lot size too small") + // ErrMultisigPartialSend is returned when an error occurs after creating + // a multisig and sending some amount of funds. + ErrMultisigPartialSend = dex.ErrorKind("multisig partially sent") + // InternalNodeLoggerName is the name for a logger that is used to fine // tune log levels for only loggers using this name. InternalNodeLoggerName = "INTL" @@ -816,6 +820,36 @@ type Bonder interface { // required for efficient client bond management. } +type PaymentMultisig struct { + NRequired, Locktime int64 + AssetID uint32 + SignerXpubs [][]byte + AddrToVal map[string]float64 + SpendingTx *PaymentMultisigTx +} + +type PaymentMultisigTx struct { + TxHex string `json:"txhex"` + HasSigs []bool `json:"hassigs"` +} + +// Multisigner is a wallet capable of sending payments to multisig addresses. +type Multisigner interface { + Broadcaster + // SendFundsToMultisig sends amounts to a multisig address, then creates + // a transaction that spends those funds. ErrMultisigPartialSend and + // partial results are returned if an error happens after sending funds. + SendFundsToMultisig(ctx context.Context, pm *PaymentMultisig) (*PaymentMultisigTx, error) + // SignMultisig signs the pmTx with the supplied privateKey and inserts + // the signature at idx among the other signatures. + SignMultisig(ctx context.Context, pmTx *PaymentMultisigTx, privKey []byte) (*PaymentMultisigTx, error) + // RefundMultisig refunds a multisig if it is after the locktime and we + // are the sender. + RefundMultisig(ctx context.Context, pmTx *PaymentMultisigTx) (txHash string, err error) + // ViewPaymentMultisig returns a tx hex in human readable json format. + ViewPaymentMultisig(pmTx *PaymentMultisigTx) (string, error) +} + // Rescanner is a wallet implementation with rescan functionality. type Rescanner interface { // Rescan performs a rescan and block until it is done. If no birthday is diff --git a/client/core/core.go b/client/core/core.go index 55c844c30b..5508f682a8 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -1501,9 +1501,10 @@ type Core struct { credMtx sync.RWMutex credentials *db.PrimaryCredentials - loginMtx sync.Mutex - loggedIn bool - bondXPriv *hdkeychain.ExtendedKey // derived from creds.EncSeed on login + loginMtx sync.Mutex + loggedIn bool + bondXPriv *hdkeychain.ExtendedKey // derived from creds.EncSeed on login + multisigXPriv *hdkeychain.ExtendedKey // derived from creds.EncSeed on login seedGenerationTime uint64 @@ -4581,6 +4582,10 @@ func (c *Core) Login(pw []byte) error { if err != nil { return false, fmt.Errorf("error deriving mesh private key: %w", err) } + c.multisigXPriv, err = deriveMultisigXPriv(seed) + if err != nil { + return false, fmt.Errorf("error deriving multisig private key: %w", err) + } if c.cfg.Mesh && c.net == dex.Simnet { mesh, err := mesh.New(&mesh.Config{ @@ -4921,6 +4926,8 @@ func (c *Core) Logout() error { c.bondXPriv.Zero() c.bondXPriv = nil + c.multisigXPriv.Zero() + c.multisigXPriv = nil c.loggedIn = false diff --git a/client/core/core_test.go b/client/core/core_test.go index fd011595e7..6876bb4270 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -666,6 +666,18 @@ func (tdb *TDB) Language() (string, error) { return "en-US", nil } +func (tdb *TDB) NextMultisigKeyIndex(assetID uint32) (uint32, error) { + return 0, nil +} + +func (tdb *TDB) StoreMultisigIndexForPubkey(assetID, idx uint32, pubkey [33]byte) error { + return nil +} + +func (tdb *TDB) MultisigIndexForPubkey(assetID uint32, pubkey [33]byte) (uint32, error) { + return 0, nil +} + type tCoin struct { id []byte diff --git a/client/core/multisig.go b/client/core/multisig.go new file mode 100644 index 0000000000..ecd7a2e32a --- /dev/null +++ b/client/core/multisig.go @@ -0,0 +1,360 @@ +package core + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex/keygen" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/hdkeychain/v3" +) + +const csvVersion = 0 + +func (c *Core) parsePaymentMultisigCVS(csvFilePath string) (pm *asset.PaymentMultisig, header []byte, err error) { + b, err := os.ReadFile(csvFilePath) + if err != nil { + return nil, nil, fmt.Errorf("error reading file: %v", err) + } + // Find start of msg tx json array. + txStartIdx := bytes.IndexRune(b, '{') + hasTx := txStartIdx > 0 + var txB []byte + if hasTx { + txB = make([]byte, len(b)-txStartIdx) + copy(txB, b[txStartIdx:]) + b = b[:txStartIdx] + } + lines := bytes.Split(b, []byte("\n")) + i, nLines := 0, len(lines) + nextLine := func() ([]byte, bool) { + defer func() { + i++ + }() + for { + if i >= nLines { + return nil, false + } + if len(lines[i]) == 0 || lines[i][0] == ';' { + i++ + continue + } + return lines[i], true + } + } + version, ok := nextLine() + if !ok { + return nil, nil, errors.New("unable to find asset id") + } + ver, err := strconv.Atoi(string(version)) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode version: %v", err) + } + if ver != csvVersion { + return nil, nil, fmt.Errorf("can only parse version %d csv file", csvVersion) + } + assetIDB, ok := nextLine() + if !ok { + return nil, nil, errors.New("unable to find asset id") + } + assetID, err := strconv.Atoi(string(assetIDB)) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode asset id: %v", err) + } + durB, ok := nextLine() + if !ok { + return nil, nil, errors.New("unable to find locktime duration") + } + lockDuration, err := time.ParseDuration(string(durB)) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode duration: %v", err) + } + locktime := time.Now().Add(lockDuration).Unix() + nRequiredB, ok := nextLine() + if !ok { + return nil, nil, errors.New("unable to find number of sigs required") + } + nRequired, err := strconv.Atoi(string(nRequiredB)) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode locktime: %v", err) + } + xPubsLineB, ok := nextLine() + if !ok { + return nil, nil, errors.New("unable to find xpubs") + } + var signerXpubs [][]byte + for i, xPubB := range bytes.Split(xPubsLineB, []byte(",")) { + pubKey, err := hex.DecodeString(string(xPubB)) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode xpub hex: %v", err) + } + if len(pubKey) != 33 { + return nil, nil, fmt.Errorf("invalid pubkey at index %d", i) + } + signerXpubs = append(signerXpubs, pubKey) + } + if nRequired > len(signerXpubs) { + return nil, nil, errors.New("more sigs required than signers") + } + addrToVal := make(map[string]float64) + for avl, ok := nextLine(); ok; avl, ok = nextLine() { + addrAmt := bytes.Split(avl, []byte(",")) + if len(addrAmt) != 2 { + return nil, nil, errors.New("unable to find amt that goes with addr") + } + amt, err := strconv.ParseFloat(string(addrAmt[1]), 64) + if err != nil { + return nil, nil, fmt.Errorf("unable to decode amt: %v", err) + } + if amt < 0 { + return nil, nil, errors.New("only positive values allowed") + } + addrToVal[string(addrAmt[0])] = amt + } + var tx *asset.PaymentMultisigTx + if hasTx { + tx = new(asset.PaymentMultisigTx) + if err := json.Unmarshal(txB, tx); err != nil { + return nil, nil, fmt.Errorf("unable to unmarshal transactions: %v", err) + } + } + return &asset.PaymentMultisig{ + AssetID: uint32(assetID), + NRequired: int64(nRequired), + Locktime: locktime, + SignerXpubs: signerXpubs, + AddrToVal: addrToVal, + SpendingTx: tx, + }, b, nil +} + +func deriveMultisigXPriv(seed []byte) (*hdkeychain.ExtendedKey, error) { + return keygen.GenDeepChild(seed, []uint32{hdKeyPurposeMulti}) +} + +func deriveMultisigKey(multisigXPriv *hdkeychain.ExtendedKey, assetID, multisigIndex uint32) (*secp256k1.PrivateKey, error) { + kids := []uint32{ + assetID + hdkeychain.HardenedKeyStart, + multisigIndex, + } + extKey, err := keygen.GenDeepChildFromXPriv(multisigXPriv, kids) + if err != nil { + return nil, fmt.Errorf("GenDeepChild error: %w", err) + } + privB, err := extKey.SerializedPrivKey() + if err != nil { + return nil, fmt.Errorf("SerializedPrivKey error: %w", err) + } + priv := secp256k1.PrivKeyFromBytes(privB) + return priv, nil +} + +func (c *Core) multisigKeyIdx(assetID, idx uint32) (*secp256k1.PrivateKey, error) { + c.loginMtx.Lock() + defer c.loginMtx.Unlock() + + if c.multisigXPriv == nil { + return nil, errors.New("not logged in") + } + + return deriveMultisigKey(c.multisigXPriv, assetID, idx) +} + +// nextMultisigKey generates the private key for the next multisig, incrementing a +// persistent multisig index counter. This method requires login to decrypt and set +// the multisig xpriv, so use the multisigKeysReady method to ensure it is ready first. +// The multisig key index is returned so the same key may be regenerated. +func (c *Core) nextMultisigKey(assetID uint32) (*secp256k1.PrivateKey, uint32, error) { + c.loginMtx.Lock() + defer c.loginMtx.Unlock() + + if c.multisigXPriv == nil { + return nil, 0, errors.New("not logged in") + } + + nextMultisigKeyIndex, err := c.db.NextMultisigKeyIndex(assetID) + if err != nil { + return nil, 0, fmt.Errorf("NextMultisigIndex: %v", err) + } + + priv, err := deriveMultisigKey(c.multisigXPriv, assetID, nextMultisigKeyIndex) + if err != nil { + return nil, 0, fmt.Errorf("multisigKeyIdx: %v", err) + } + + var pubkey [33]byte + copy(pubkey[:], priv.PubKey().SerializeCompressed()) + if err := c.db.StoreMultisigIndexForPubkey(assetID, nextMultisigKeyIndex, pubkey); err != nil { + return nil, 0, fmt.Errorf("unable to store pubkey for index: %v", err) + } + return priv, nextMultisigKeyIndex, nil +} + +// PaymentMultisigPubkey returns the next multisig signing pubkey and stores its +// index in the db. +func (c *Core) PaymentMultisigPubkey(assetID uint32) (string, error) { + priv, _, err := c.nextMultisigKey(assetID) + if err != nil { + return "", err + } + defer priv.Zero() + return hex.EncodeToString(priv.PubKey().SerializeCompressed()), nil +} + +func (c *Core) preparePaymentMultisig(csvFilePath string) (pm *asset.PaymentMultisig, multisigner asset.Multisigner, xcwallet *xcWallet, + writeToFile func(pmTx *asset.PaymentMultisigTx) error, err error) { + pm, header, err := c.parsePaymentMultisigCVS(csvFilePath) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("unable to parse csv file: %v", err) + } + var found bool + wallet, found := c.wallet(pm.AssetID) + if !found || !wallet.connected() { + return nil, nil, nil, nil, fmt.Errorf("multisig asset wallet %v does not exist or is not connected", unbip(pm.AssetID)) + } + multisigner, ok := wallet.Wallet.(asset.Multisigner) + if !ok { + return nil, nil, nil, nil, fmt.Errorf("wallet %v is not an asset.Multisigner", unbip(pm.AssetID)) + } + + return pm, multisigner, wallet, func(pmTx *asset.PaymentMultisigTx) error { + b, err := json.Marshal(pmTx) + if err != nil { + return fmt.Errorf("unable to marshal multisig csv :%v", err) + } + header = append(header, b...) + return os.WriteFile(csvFilePath, header, 0644) + }, nil +} + +// SendFundsToMultisig sends amounts to a multisig address, then creates a +// transaction that spends those funds. Writes data back to csvFilePath. +func (c *Core) SendFundsToMultisig(csvFilePath string) error { + pm, multisigner, wallet, writeToFile, err := c.preparePaymentMultisig(csvFilePath) + if err != nil { + return err + } + if pm.SpendingTx != nil { + return errors.New("it appears funds have already been sent. spending tx not nil") + } + _, err = wallet.refreshUnlock() + if err != nil { + return fmt.Errorf("multisig asset wallet %v is locked", unbip(pm.AssetID)) + } + pmTx, err := multisigner.SendFundsToMultisig(c.ctx, pm) + if err != nil { + if errors.Is(err, asset.ErrMultisigPartialSend) { + if writeErr := writeToFile(pmTx); err != nil { + return fmt.Errorf("making multisig ended with partial error and we were unable "+ + "to write the multisig tx to file: %v AND %v", err, writeErr) + } + return fmt.Errorf("making multisig ended with partial error but some info was added to the csv file: %v", err) + } + return fmt.Errorf("unable to send multisig funds: %v", err) + } + + return writeToFile(pmTx) +} + +// SignMultisig signs the pmTx with the supplied privateKey and inserts the +// signature at idx among the other signatures. Writes back to csvFilePath. +func (c *Core) SignMultisig(csvFilePath string, signIdx int) error { + pm, multisigner, wallet, writeToFile, err := c.preparePaymentMultisig(csvFilePath) + if err != nil { + return err + } + if pm.SpendingTx == nil { + return errors.New("no tx to sign") + } + _, err = wallet.refreshUnlock() + if err != nil { + return fmt.Errorf("multisig asset wallet %v is locked", unbip(pm.AssetID)) + } + if len(pm.SignerXpubs) <= signIdx { + return fmt.Errorf("not enough signers %d for sign index of %d", len(pm.SignerXpubs), signIdx) + } + var pubkey [33]byte + copy(pubkey[:], pm.SignerXpubs[signIdx]) + idx, err := c.db.MultisigIndexForPubkey(pm.AssetID, pubkey) + if err != nil { + return fmt.Errorf("unable to find the index for pubkey %x and asset id %d. If this wallet was restored, "+ + "try creating signing keys with that asset until you see the used key returned.", pm.SignerXpubs[signIdx], pm.AssetID) + } + c.loginMtx.Lock() + + if c.multisigXPriv == nil { + c.loginMtx.Unlock() + return errors.New("not logged in") + } + + priv, err := deriveMultisigKey(c.multisigXPriv, pm.AssetID, idx) + c.loginMtx.Unlock() + if err != nil { + return fmt.Errorf("unable to derive multisig private key: %v", err) + } + + pmTx, err := multisigner.SignMultisig(c.ctx, pm.SpendingTx, priv.Serialize()) + if err != nil { + return fmt.Errorf("unable to sign multisig: %v", err) + } + + return writeToFile(pmTx) +} + +// RefundPaymentMultisig refunds a multisig if it is after the locktime and we are the +// sender. +func (c *Core) RefundPaymentMultisig(csvFilePath string) (string, error) { + pm, multisigner, wallet, _, err := c.preparePaymentMultisig(csvFilePath) + if err != nil { + return "", err + } + if pm.SpendingTx == nil { + return "", errors.New("no tx to refund") + } + _, err = wallet.refreshUnlock() + if err != nil { + return "", fmt.Errorf("multisig asset wallet %v is locked", unbip(pm.AssetID)) + } + return multisigner.RefundMultisig(c.ctx, pm.SpendingTx) +} + +// ViewPaymentMultisig returns a json encoding of the spending tx for human +// viewing. +func (c *Core) ViewPaymentMultisig(csvFilePath string) (string, error) { + pm, multisigner, _, _, err := c.preparePaymentMultisig(csvFilePath) + if err != nil { + return "", err + } + if pm.SpendingTx == nil { + return "", errors.New("no tx to view") + } + return multisigner.ViewPaymentMultisig(pm.SpendingTx) +} + +// SendPaymentMultisig sends the multisig hex. Will not error if there aren't +// enough signatures if using spv. +func (c *Core) SendPaymentMultisig(csvFilePath string) (string, error) { + pm, multisigner, _, _, err := c.preparePaymentMultisig(csvFilePath) + if err != nil { + return "", err + } + if pm.SpendingTx == nil { + return "", errors.New("no tx to send") + } + txB, err := hex.DecodeString(pm.SpendingTx.TxHex) + if err != nil { + return "", err + } + coinID, err := multisigner.SendTransaction(txB) + if err != nil { + return "", err + } + return coinIDString(pm.AssetID, coinID), nil +} diff --git a/client/core/multisig_test.go b/client/core/multisig_test.go new file mode 100644 index 0000000000..6a9c8b64f5 --- /dev/null +++ b/client/core/multisig_test.go @@ -0,0 +1,288 @@ +package core + +import ( + "bytes" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "decred.org/dcrdex/client/asset" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +func TestDeriveMultisigXPriv(t *testing.T) { + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i) + } + + xPriv, err := deriveMultisigXPriv(seed) + if err != nil { + t.Fatalf("deriveMultisigXPriv error: %v", err) + } + if xPriv == nil { + t.Fatal("expected non-nil xPriv") + } + + // Derive again with same seed should give same result + xPriv2, err := deriveMultisigXPriv(seed) + if err != nil { + t.Fatalf("deriveMultisigXPriv error on second call: %v", err) + } + + if xPriv.String() != xPriv2.String() { + t.Fatal("same seed should produce same xPriv") + } + + // Different seed should produce different xPriv + seed2 := make([]byte, 32) + for i := range seed2 { + seed2[i] = byte(i + 1) + } + xPriv3, err := deriveMultisigXPriv(seed2) + if err != nil { + t.Fatalf("deriveMultisigXPriv error with different seed: %v", err) + } + if xPriv.String() == xPriv3.String() { + t.Fatal("different seed should produce different xPriv") + } +} + +func TestDeriveMultisigKey(t *testing.T) { + seed := make([]byte, 32) + for i := range seed { + seed[i] = byte(i) + } + + xPriv, err := deriveMultisigXPriv(seed) + if err != nil { + t.Fatalf("deriveMultisigXPriv error: %v", err) + } + + assetID := uint32(42) // DCR + multisigIndex := uint32(0) + + priv, err := deriveMultisigKey(xPriv, assetID, multisigIndex) + if err != nil { + t.Fatalf("deriveMultisigKey error: %v", err) + } + if priv == nil { + t.Fatal("expected non-nil private key") + } + + // Same inputs should give same key + priv2, err := deriveMultisigKey(xPriv, assetID, multisigIndex) + if err != nil { + t.Fatalf("deriveMultisigKey error on second call: %v", err) + } + if !bytes.Equal(priv.Serialize(), priv2.Serialize()) { + t.Fatal("same inputs should produce same key") + } + + // Different index should give different key + priv3, err := deriveMultisigKey(xPriv, assetID, multisigIndex+1) + if err != nil { + t.Fatalf("deriveMultisigKey error with different index: %v", err) + } + if bytes.Equal(priv.Serialize(), priv3.Serialize()) { + t.Fatal("different index should produce different key") + } + + // Different asset should give different key + priv4, err := deriveMultisigKey(xPriv, assetID+1, multisigIndex) + if err != nil { + t.Fatalf("deriveMultisigKey error with different asset: %v", err) + } + if bytes.Equal(priv.Serialize(), priv4.Serialize()) { + t.Fatal("different asset should produce different key") + } +} + +func TestParsePaymentMultisigCSV(t *testing.T) { + tmpDir := t.TempDir() + // Generate test pubkeys + pk1 := secp256k1.PrivKeyFromBytes(make([]byte, 32)).PubKey() + pk2Seed := make([]byte, 32) + pk2Seed[0] = 1 + pk2 := secp256k1.PrivKeyFromBytes(pk2Seed).PubKey() + + pk1Hex := hex.EncodeToString(pk1.SerializeCompressed()) + pk2Hex := hex.EncodeToString(pk2.SerializeCompressed()) + + tests := []struct { + name string + csvContent string + wantErr bool + checkResult func(t *testing.T, pm *asset.PaymentMultisig) + }{{ + name: "valid csv", + csvContent: `0 +42 +1h +2 +` + pk1Hex + `,` + pk2Hex + ` +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,40.1 +`, + wantErr: false, + checkResult: func(t *testing.T, pm *asset.PaymentMultisig) { + if pm.AssetID != 42 { + t.Errorf("expected asset ID 42, got %d", pm.AssetID) + } + if pm.NRequired != 2 { + t.Errorf("expected nRequired 2, got %d", pm.NRequired) + } + if len(pm.SignerXpubs) != 2 { + t.Errorf("expected 2 xpubs, got %d", len(pm.SignerXpubs)) + } + if len(pm.AddrToVal) != 1 { + t.Errorf("expected 1 address, got %d", len(pm.AddrToVal)) + } + if pm.AddrToVal["SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg"] != 40.1 { + t.Error("wrong amount for address") + } + }, + }, { + name: "with comments", + csvContent: `; This is a comment +0 +; asset ID +42 +; duration +2h +; nRequired +1 +` + pk1Hex + `,` + pk2Hex + ` +; addresses +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,10 +`, + wantErr: false, + checkResult: func(t *testing.T, pm *asset.PaymentMultisig) { + if pm.NRequired != 1 { + t.Errorf("expected nRequired 1, got %d", pm.NRequired) + } + }, + }, { + name: "invalid version", + csvContent: `1 +42 +1h +2 +` + pk1Hex + `,` + pk2Hex + ` +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,40.1 +`, + wantErr: true, + }, { + name: "more sigs required than signers", + csvContent: `0 +42 +1h +3 +` + pk1Hex + `,` + pk2Hex + ` +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,40.1 +`, + wantErr: true, + }, { + name: "invalid pubkey", + csvContent: `0 +42 +1h +1 +invalidhex,` + pk2Hex + ` +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,40.1 +`, + wantErr: true, + }, { + name: "negative amount", + csvContent: `0 +42 +1h +1 +` + pk1Hex + `,` + pk2Hex + ` +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,-10 +`, + wantErr: true, + }, { + name: "empty file", + csvContent: ``, + wantErr: true, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Write test CSV file + csvPath := filepath.Join(tmpDir, test.name+".csv") + if err := os.WriteFile(csvPath, []byte(test.csvContent), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + // Create a minimal Core just to test parsing + c := &Core{} + pm, _, err := c.parsePaymentMultisigCVS(csvPath) + + if test.wantErr { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pm == nil { + t.Fatal("expected non-nil PaymentMultisig") + } + if test.checkResult != nil { + test.checkResult(t, pm) + } + }) + } +} + +func TestParsePaymentMultisigCSVWithTx(t *testing.T) { + tmpDir := t.TempDir() + + pk1 := secp256k1.PrivKeyFromBytes(make([]byte, 32)).PubKey() + pk2Seed := make([]byte, 32) + pk2Seed[0] = 1 + pk2 := secp256k1.PrivKeyFromBytes(pk2Seed).PubKey() + + pk1Hex := hex.EncodeToString(pk1.SerializeCompressed()) + pk2Hex := hex.EncodeToString(pk2.SerializeCompressed()) + + csvContent := `0 +42 +1h +2 +` + pk1Hex + `,` + pk2Hex + ` +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,40.1 +{"txhex":"0100","hassigs":[false,false]}` + + csvPath := filepath.Join(tmpDir, "with_tx.csv") + if err := os.WriteFile(csvPath, []byte(csvContent), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + c := &Core{} + pm, _, err := c.parsePaymentMultisigCVS(csvPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pm.SpendingTx == nil { + t.Fatal("expected SpendingTx to be parsed") + } + if pm.SpendingTx.TxHex != "0100" { + t.Errorf("expected TxHex '0100', got '%s'", pm.SpendingTx.TxHex) + } + if len(pm.SpendingTx.HasSigs) != 2 { + t.Errorf("expected 2 HasSigs entries, got %d", len(pm.SpendingTx.HasSigs)) + } +} + +func TestParsePaymentMultisigCSVFileNotFound(t *testing.T) { + c := &Core{} + _, _, err := c.parsePaymentMultisigCVS("/nonexistent/path/file.csv") + if err == nil { + t.Error("expected error for non-existent file") + } +} diff --git a/client/core/multisigexample.csv b/client/core/multisigexample.csv new file mode 100644 index 0000000000..0e5fec6c0c --- /dev/null +++ b/client/core/multisigexample.csv @@ -0,0 +1,19 @@ +;Version. Checked when parsing. Should be version 0 currently. +0 +;asset ID as specified in Slip 44 https://github.com/satoshilabs/slips/blob/master/slip-0044.md, 42 for decred +42 +;duration from creation that the refund path can be taken. i.e. 72h +0s +;number of signatures needed for the multisig path +2 +;xpubs separated by comma. Order cannot be changed once tx is created +02a47fa09a223f2aa079edf85a7c2d4f8720ee63e502ee2869afab7de234b80c,035170f.... +;address to amount in coins separated with commas one new line per +SsfLJbjXHTjGGqNd7uaNHL8EukmNgEQDNbg,40.1 +SsaDp2Ae7EUMgbC7B8Eo6qmQw2szu7HbecG,100.002 +Sso9bUQ2ZZZzUMLwQsCM6m6QjR8HNaQLbbf,5 +;below this a new line and then a json containing a transaction will be filled in +;after initial funds are sent and the spending transaction is created. Pass this +;file around to receive signatures. The original creator can use this to refund +;after the locktime has passed as well. The creator must not loose this file +;before funds have moved from the multisig. diff --git a/client/core/types.go b/client/core/types.go index 0e5d4f1b59..04d3c3154a 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -40,6 +40,8 @@ const ( hdKeyPurposeBonds uint32 = hdkeychain.HardenedKeyStart + 0x626f6e64 // ASCII "bond" // hdKeyPurposeMesh is the BIP-43 purpose field for mesh keys. hdKeyPurposeMesh uint32 = hdkeychain.HardenedKeyStart + 0x6d657368 // ASCII "mesh" + // hdKeyPurposeMulti is the BIP-43 purpose field for multisig keys. + hdKeyPurposeMulti uint32 = hdkeychain.HardenedKeyStart + 0x6D756C74 // ASCII "mult" ) // errorSet is a slice of orders with a prefix prepended to the Error output. diff --git a/client/db/bolt/db.go b/client/db/bolt/db.go index c94d961d0e..2eb6af6586 100644 --- a/client/db/bolt/db.go +++ b/client/db/bolt/db.go @@ -69,6 +69,8 @@ var ( notesBucket = []byte("notes") pokesBucket = []byte("pokes") credentialsBucket = []byte("credentials") + multisigIndexesBucket = []byte("multiIndexes") + multisigPubKeysBucket = []byte("multiPubKeys") // value keys versionKey = []byte("version") @@ -181,7 +183,8 @@ func NewDB(dbPath string, logger dex.Logger, opts ...Opts) (dexdb.DB, error) { activeOrdersBucket, archivedOrdersBucket, activeMatchesBucket, archivedMatchesBucket, walletsBucket, notesBucket, credentialsBucket, - botProgramsBucket, pokesBucket, + botProgramsBucket, pokesBucket, multisigIndexesBucket, + multisigPubKeysBucket, }); err != nil { return nil, err } @@ -673,6 +676,62 @@ func (db *BoltDB) NextBondKeyIndex(assetID uint32) (uint32, error) { }) } +// NextMultisigKeyIndex returns the next multisig key index and increments the stored +// value so that subsequent calls will always return a higher index. +func (db *BoltDB) NextMultisigKeyIndex(assetID uint32) (uint32, error) { + var multisigIndex uint32 + return multisigIndex, db.Update(func(tx *bbolt.Tx) error { + bkt := tx.Bucket(multisigIndexesBucket) + if bkt == nil { + return errors.New("no multisig indexes bucket") + } + + thisMultisigIdxKey := uint32Bytes(assetID) + multisigIndexB := bkt.Get(thisMultisigIdxKey) + if len(multisigIndexB) != 0 { + multisigIndex = intCoder.Uint32(multisigIndexB) + } + return bkt.Put(thisMultisigIdxKey, uint32Bytes(multisigIndex+1)) + }) +} + +// StoreMultisigIndexForPubkey stores the index for pubkey and asset id. +func (db *BoltDB) StoreMultisigIndexForPubkey(assetID, idx uint32, pubkey [33]byte) error { + return db.Update(func(tx *bbolt.Tx) error { + outerBkt := tx.Bucket(multisigPubKeysBucket) + if outerBkt == nil { + return errors.New("no multisig indexes bucket") + } + + bkt, err := outerBkt.CreateBucketIfNotExists(uint32Bytes(assetID)) + if err != nil { + return err + } + return bkt.Put(pubkey[:], uint32Bytes(idx)) + }) +} + +// MultisigIndexForPubkey returns the index for pubkey and asset id if stored. +func (db *BoltDB) MultisigIndexForPubkey(assetID uint32, pubkey [33]byte) (uint32, error) { + var multisigIndex uint32 + return multisigIndex, db.View(func(tx *bbolt.Tx) error { + outerBkt := tx.Bucket(multisigPubKeysBucket) + if outerBkt == nil { + return errors.New("no multisig indexes bucket") + } + bkt := outerBkt.Bucket(uint32Bytes(assetID)) + if bkt == nil { + return fmt.Errorf("no multisig bucket for asset id %d", assetID) + } + idxB := bkt.Get(pubkey[:]) + if len(idxB) == 0 { + return errors.New("pubkey index not found") + } + multisigIndex = intCoder.Uint32(idxB) + return nil + }) +} + // UpdateAccountInfo updates the account info for an existing account with // the same Host as the parameter. If no account exists with this host, // an error is returned. diff --git a/client/db/bolt/db_test.go b/client/db/bolt/db_test.go index aff6f8ef49..c63e1a27fe 100644 --- a/client/db/bolt/db_test.go +++ b/client/db/bolt/db_test.go @@ -1775,3 +1775,32 @@ func TestPokes(t *testing.T) { t.Fatal("Result from second LoadPokes wasn't empty") } } + +func TestStoreMultisigIndexForPubkey(t *testing.T) { + boltdb, shutdown := newTestDB(t) + defer shutdown() + pubkey := [33]byte{} + assetID := uint32(42) + idx := uint32(0) + if err := boltdb.StoreMultisigIndexForPubkey(assetID, idx, pubkey); err != nil { + t.Fatal(err) + } +} + +func TestMultisigIndexForPubkey(t *testing.T) { + boltdb, shutdown := newTestDB(t) + defer shutdown() + pubkey := [33]byte{} + assetID := uint32(42) + idx := uint32(0) + if err := boltdb.StoreMultisigIndexForPubkey(assetID, idx, pubkey); err != nil { + t.Fatal(err) + } + gotIdx, err := boltdb.MultisigIndexForPubkey(assetID, pubkey) + if err != nil { + t.Fatal(err) + } + if idx != gotIdx { + t.Fatalf("wanted %d but got %d", idx, gotIdx) + } +} diff --git a/client/db/interface.go b/client/db/interface.go index 215de2dcec..7cbc719f47 100644 --- a/client/db/interface.go +++ b/client/db/interface.go @@ -155,4 +155,13 @@ type DB interface { SetLanguage(lang string) error // Language gets the language stored with SetLanguage. Language() (string, error) + // NextMultisigKeyIndex returns the next multisig key index and increments the + // stored value so that subsequent calls will always return a higher index. + NextMultisigKeyIndex(assetID uint32) (uint32, error) + // StoreMultisigIndexForPubkey stores the key index for the compressed + // pubkey bytes of assetID. + StoreMultisigIndexForPubkey(assetID, idx uint32, pubkey [33]byte) error + // MultisigIndexForPubkey returns the key index for the compressed + // pubkey bytes of assetID if stored. + MultisigIndexForPubkey(assetID uint32, pubkey [33]byte) (uint32, error) } diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 50bc19e361..19f4029fde 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -73,6 +73,12 @@ const ( bridgeHistoryRoute = "bridgehistory" supportedBridgesRoute = "supportedbridges" bridgeFeesAndLimitsRoute = "bridgefeesandlimits" + paymentMultisigPubkeyRoute = "paymentmultisigpubkey" + sendFundsToMultisigRoute = "sendfundstomultisig" + signMultisigRoute = "signmultisig" + refundPaymentMultisigRoute = "refundpaymentmultisig" + viewPaymentMultisigRoute = "viewpaymentmultisig" + sendPaymentMultisigRoute = "sendpaymentmultisig" ) const ( @@ -158,6 +164,12 @@ var routes = map[string]func(s *RPCServer, params *RawParams) *msgjson.ResponseP bridgeHistoryRoute: handleBridgeHistory, supportedBridgesRoute: handleSupportedBridges, bridgeFeesAndLimitsRoute: handleBridgeFeesAndLimits, + paymentMultisigPubkeyRoute: handlePaymentMultisigPubkey, + sendFundsToMultisigRoute: handleSendFundsToMultisig, + signMultisigRoute: handleSignMultisig, + refundPaymentMultisigRoute: handleRefundPaymentMultisig, + viewPaymentMultisigRoute: handleViewPaymentMultisig, + sendPaymentMultisigRoute: handleSendPaymentMultisig, } // handleHelp handles requests for help. Returns general help for all commands @@ -1289,6 +1301,87 @@ func handleBridgeFeesAndLimits(s *RPCServer, params *RawParams) *msgjson.Respons return createResponse(bridgeFeesAndLimitsRoute, result, nil) } +func handlePaymentMultisigPubkey(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + if len(params.Args) != 1 { + return usage(paymentMultisigPubkeyRoute, fmt.Errorf("expected 1 arg, got %d", len(params.Args))) + } + + assetID, err := strconv.ParseUint(params.Args[0], 10, 32) + if err != nil { + return usage(paymentMultisigPubkeyRoute, fmt.Errorf("error parsing assetID: %v", err)) + } + + pubkey, err := s.core.PaymentMultisigPubkey(uint32(assetID)) + if err != nil { + resErr := msgjson.NewError(msgjson.RPCPaymentMultisigError, "unable to get pubkey: %v", err) + return createResponse(paymentMultisigPubkeyRoute, nil, resErr) + } + return createResponse(paymentMultisigPubkeyRoute, pubkey, nil) +} + +func handleSendFundsToMultisig(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + if len(params.Args) != 1 { + return usage(sendFundsToMultisigRoute, fmt.Errorf("expected 1 arg, got %d", len(params.Args))) + } + if err := s.core.SendFundsToMultisig(params.Args[0]); err != nil { + resErr := msgjson.NewError(msgjson.RPCPaymentMultisigError, "unable to send funds to multisig: %v", err) + return createResponse(sendFundsToMultisigRoute, nil, resErr) + } + res := "csv updated" + return createResponse(sendFundsToMultisigRoute, &res, nil) +} + +func handleSignMultisig(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + if len(params.Args) != 2 { + return usage(signMultisigRoute, fmt.Errorf("expected 1 arg, got %d", len(params.Args))) + } + idx, err := checkUIntArg(params.Args[1], "signIdx", 32) + if err != nil { + return usage(signMultisigRoute, fmt.Errorf("unable to decode index: %v", err)) + } + if err := s.core.SignMultisig(params.Args[0], int(idx)); err != nil { + resErr := msgjson.NewError(msgjson.RPCPaymentMultisigError, "unable to sign multisig: %v", err) + return createResponse(signMultisigRoute, nil, resErr) + } + res := "csv updated" + return createResponse(sendFundsToMultisigRoute, &res, nil) +} + +func handleRefundPaymentMultisig(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + if len(params.Args) != 1 { + return usage(refundPaymentMultisigRoute, fmt.Errorf("expected 1 arg, got %d", len(params.Args))) + } + res, err := s.core.RefundPaymentMultisig(params.Args[0]) + if err != nil { + resErr := msgjson.NewError(msgjson.RPCPaymentMultisigError, "unable to refund multisig: %v", err) + return createResponse(refundPaymentMultisigRoute, nil, resErr) + } + return createResponse(refundPaymentMultisigRoute, &res, nil) +} + +func handleViewPaymentMultisig(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + if len(params.Args) != 1 { + return usage(refundPaymentMultisigRoute, fmt.Errorf("expected 1 arg, got %d", len(params.Args))) + } + res, err := s.core.ViewPaymentMultisig(params.Args[0]) + if err != nil { + resErr := msgjson.NewError(msgjson.RPCPaymentMultisigError, "unable to view multisig: %v", err) + return createResponse(refundPaymentMultisigRoute, nil, resErr) + } + return createResponse(refundPaymentMultisigRoute, &res, nil) +} + +func handleSendPaymentMultisig(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + if len(params.Args) != 1 { + return usage(refundPaymentMultisigRoute, fmt.Errorf("expected 1 arg, got %d", len(params.Args))) + } + res, err := s.core.SendPaymentMultisig(params.Args[0]) + if err != nil { + resErr := msgjson.NewError(msgjson.RPCPaymentMultisigError, "unable to send multisig: %v", err) + return createResponse(refundPaymentMultisigRoute, nil, resErr) + } + return createResponse(refundPaymentMultisigRoute, &res, nil) +} // format concatenates thing and tail. If thing is empty, returns an empty // string. @@ -2133,4 +2226,49 @@ an spv wallet and enables options to view and set the vsp. toAssetID (int): The asset's BIP-44 registered coin index on the "to" chain. bridgeName (string): The name of the bridge to query.`, }, + paymentMultisigPubkeyRoute: { + argsShort: `assetID`, + cmdSummary: "Get a multisig pubkey for asset id.", + argsLong: `Args: + assetID (int): The asset's BIP-44 registered coin index to get a pubkey for.`, + returns: `Returns: + string: a pubkey. The index is stored in the db and this pubkey will not be returned again`, + }, + sendFundsToMultisigRoute: { + argsShort: `csvFilePath`, + cmdSummary: "Send funds to a payment multisig.", + argsLong: `Args: + csvFilePath (string): The csv file path from the point of view of the client, not bwctl. Will be written to.`, + }, + signMultisigRoute: { + argsShort: `csvFilePath sigIndex`, + cmdSummary: "Sign a payment multisig.", + argsLong: `Args: + csvFilePath (string): The csv file path from the point of view of the client, not bwctl. Will be written to. + sigIndex (int): The pubkey index we own and are able to sign.`, + }, + refundPaymentMultisigRoute: { + argsShort: `csvFilePath`, + cmdSummary: "Refund a payment multisig.", + argsLong: `Args: + csvFilePath (string): The csv file path from the point of view of the client, not bwctl.`, + returns: `Returns: + string: the refund tx hash`, + }, + viewPaymentMultisigRoute: { + argsShort: `csvFilePath`, + cmdSummary: "view a payment multisig.", + argsLong: `Args: + csvFilePath (string): The csv file path from the point of view of the client, not bwctl.`, + returns: `Returns: + string: tx in json format`, + }, + sendPaymentMultisigRoute: { + argsShort: `csvFilePath`, + cmdSummary: "Send a payment multisig. May not error even with spv if not fully signed.", + argsLong: `Args: + csvFilePath (string): The csv file path from the point of view of the client, not bwctl.`, + returns: `Returns: + string: the sent tx hash`, + }, } diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index a0c5e25e98..5be1a99f79 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -95,6 +95,12 @@ type clientCore interface { BridgeHistory(assetID uint32, n int, refID *string, past bool) ([]*asset.WalletTransaction, error) SupportedBridgeDestinations(assetID uint32) (map[uint32][]string, error) BridgeFeesAndLimits(fromAssetID, toAssetID uint32, bridgeName string) (*core.BridgeFeesAndLimits, error) + PaymentMultisigPubkey(assetID uint32) (string, error) + SendFundsToMultisig(csvFilePath string) error + SignMultisig(csvFilePath string, signIdx int) error + RefundPaymentMultisig(csvFilePath string) (string, error) + ViewPaymentMultisig(csvFilePath string) (string, error) + SendPaymentMultisig(csvFilePath string) (string, error) // These are core's ticket buying interface. StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index cab43123d2..6a76d4ef1a 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -226,6 +226,24 @@ func (c *TCore) SupportedBridgeDestinations(assetID uint32) (map[uint32][]string func (c *TCore) BridgeFeesAndLimits(fromAssetID, toAssetID uint32, bridgeName string) (*core.BridgeFeesAndLimits, error) { return nil, nil } +func (c *TCore) PaymentMultisigPubkey(assetID uint32) (string, error) { + return "", nil +} +func (c *TCore) SendFundsToMultisig(csvFilePath string) error { + return nil +} +func (c *TCore) SignMultisig(csvFilePath string, signIdx int) error { + return nil +} +func (c *TCore) RefundPaymentMultisig(csvFilePath string) (string, error) { + return "", nil +} +func (c *TCore) ViewPaymentMultisig(csvFilePath string) (string, error) { + return "", nil +} +func (c *TCore) SendPaymentMultisig(csvFilePath string) (string, error) { + return "", nil +} func (c *TCore) AbandonTransaction(assetID uint32, txID string) error { return c.abandonTransactionErr } diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index 0cf194519b..a23c6b6c2f 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -99,6 +99,7 @@ const ( RPCUpdateRunningBotInvError // 81 RPCMMStatusError // 82 RPCBridgeError // 83 + RPCPaymentMultisigError // 84 ) // Routes are destinations for a "payload" of data. The type of data being diff --git a/dex/networks/dcr/script.go b/dex/networks/dcr/script.go index e6a1bd77c9..6e93aeba1b 100644 --- a/dex/networks/dcr/script.go +++ b/dex/networks/dcr/script.go @@ -15,6 +15,7 @@ import ( "decred.org/dcrwallet/v5/wallet/txsizes" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/txscript/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" @@ -1095,3 +1096,223 @@ func FindKeyPush(scriptVersion uint16, sigScript, contractHash []byte, chainPara return nil, fmt.Errorf("key not found") } + +func multisigScriptSize(nPub int64) uint64 { + return 1 + // OP_IF + 1 + 1 + // nRequired (push + value) + uint64(nPub)*(1+pubkeyLength) + // each pubkey with push opcode + 1 + 1 + // nPub count (push + value) + 1 + // OP_CHECKMULTISIGVERIFY + 1 + // OP_1 + 1 + // OP_ELSE + // Refund path: + 5 + // locktime (OP_PUSHDATA + 4 bytes) + 1 + // OP_CHECKLOCKTIMEVERIFY + 1 + // OP_DROP + 1 + // OP_DUP + 1 + // OP_HASH160 + 1 + 20 + // push + 20-byte hash + 1 + // OP_EQUALVERIFY + 1 + // OP_CHECKSIG + 1 // OP_ENDIF +} + +// PaymentMultisigRedeemSize returns the redeem tx size based on the number +// of signatures and pubkeys involved and the number recipients. +func PaymentMultisigRedeemTxSize(nPub, nRequired, nRecipients int64) uint64 { + redeemScriptSize := multisigScriptSize(nPub) + + scriptSigSize := 1 + // OP_1 selector + uint64(nRequired)*(1+DERSigLength) + // each sig with push + 1 + redeemScriptSize // push opcode + redeem script + + inputSize := TxInOverhead + scriptSigSize + + uint64(wire.VarIntSerializeSize(scriptSigSize)) + + return MsgTxOverhead + inputSize + uint64(nRecipients)*P2PKHOutputSize +} + +// PaymentMultisigRefundSize returns the redeem tx size based on the number +// of signatures and pubkeys involved and the number recipients. +func PaymentMultisigRefundTxSize(nPub int64) uint64 { + redeemScriptSize := multisigScriptSize(nPub) + + scriptSigSize := 1 + // OP_0 selector + 1 + DERSigLength + // sig with push + 1 + pubkeyLength + // the sender pubkey + 1 + redeemScriptSize // push opcode + redeem script + + inputSize := TxInOverhead + scriptSigSize + + uint64(wire.VarIntSerializeSize(scriptSigSize)) + + return MsgTxOverhead + inputSize + P2PKHOutputSize +} + +// MakePaymentMultisig makes a script that can be used to send funds to an address +// that requires either n signatures from signerXpubs or can be used by sender +// after locktime has passed. +// NOTE: sender should not be used as a signerXpub. If the same another participant +// will be able to use the signature with the refund path, bypassing the need for +// n signatures. +func MakePaymentMultisig(sender string, signerXpubs []*secp256k1.PublicKey, nRequired, + locktime int64, chainParams *chaincfg.Params) (redeemScript []byte, err error) { + if nRequired < 1 || nRequired > 16 { + return nil, errors.New("use nRequired between 1 and 16") + } + if len(signerXpubs) < 2 || len(signerXpubs) > 16 { + return nil, errors.New("use between 2 and 16 signers") + } + addr, err := stdaddr.DecodeAddress(sender, chainParams) + if err != nil { + return nil, fmt.Errorf("error decoding address %s: %w", sender, err) + } + p2pkh, ok := addr.(*stdaddr.AddressPubKeyHashEcdsaSecp256k1V0) + if !ok { + return nil, fmt.Errorf("address %s is not a pubkey-hash address or "+ + "signature algorithm is unsupported", sender) + } + + b := txscript.NewScriptBuilder(). + AddOp(txscript.OP_IF) + { // multisig + b.AddInt64(nRequired) + for _, xpub := range signerXpubs { + b.AddData(xpub.SerializeCompressed()) + } + b.AddInt64(int64(len(signerXpubs))) + b.AddOp(txscript.OP_CHECKMULTISIGVERIFY) + // OP_CHECKMULTISIGVERIFY will cause the script to fail if the signatures don't + // check out, but the script will still fail if it does not end with a true value. + // So, OP_1 + b.AddOp(txscript.OP_1) + } + b.AddOp(txscript.OP_ELSE) + { // refund to sender + b.AddInt64(locktime) + b.AddOp(txscript.OP_CHECKLOCKTIMEVERIFY) + b.AddOp(txscript.OP_DROP) + b.AddOp(txscript.OP_DUP) + b.AddOp(txscript.OP_HASH160) + b.AddData(p2pkh.Hash160()[:]) + b.AddOp(txscript.OP_EQUALVERIFY) + b.AddOp(txscript.OP_CHECKSIG) + } + b.AddOp(txscript.OP_ENDIF) + return b.Script() +} + +// RedeemPaymentMultisig spends a payment multisig if there are enough signatures. +func RedeemPaymentMultisig(redeemScript []byte, sigs [][]byte) ([]byte, error) { + b := txscript.NewScriptBuilder() + for _, sig := range sigs { + b.AddData(sig) + } + b.AddInt64(1) + return b.AddData(redeemScript). + Script() +} + +// SigsFromPaymentMultisig returns the signatures and redeemScript from a signed +// payment multisig. +func SigsFromPaymentMultisig(sigScript []byte) (redeemScript []byte, sigs [][]byte, err error) { + var scriptVer uint16 + tokenizer := txscript.MakeScriptTokenizer(scriptVer, sigScript) + for !tokenizer.Done() { + if !tokenizer.Next() { + err = tokenizer.Err() + return + } + sig := tokenizer.Data() + if sig == nil { + continue + } + sigs = append(sigs, sig) + } + nSigs := len(sigs) + if nSigs == 0 { + return nil, nil, errors.New("no sigs found") + } + redeemScript = sigs[nSigs-1] + sigs = sigs[:nSigs-1] + return +} + +// RefundPaymentMultisig spends a payment multisig if after the locktime and +// signed by the sender. +func RefundPaymentMultisig(redeemScript, pubKey, sig []byte) ([]byte, error) { + return txscript.NewScriptBuilder(). + AddData(sig). + AddData(pubKey). + AddInt64(0). + AddData(redeemScript). + Script() +} + +// ExtractPaymentMultisigDetails extracts details from a multisig payment script. +func ExtractPaymentMultisigDetails(script []byte) (nRequired int64, sender [ripemd160.Size]byte, + locktime int64, pubKeys []*secp256k1.PublicKey, err error) { + // OP_IF + // nRequired (OP_DATA_33 pubkey) nKeys OP_CHECKMULTISIGVERIFY OP_1 + // 1 + var (34x) + 1 + 1 + 1 + // OP_ELSE + // OP_DATA4 lockTime OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 OP_DATA_20 pkHashSender OP_EQUALVERIFY OP_CHECKSIG + // 1 + 4 + 1 + 1 + 1 + 1 + 1 + 20 + 1 + 1 + // OP_ENDIF + // + // NOTE: nRequired and nKeys are limited to numbers 1-16. Larger numbers would use OP_DATA1. + // 3 bytes if-else-endif-equalverify-checksig + // total 39 plus a variable number of 1+33 byte public keys + + if len(script) < 39 { + err = fmt.Errorf("invalid multisig payment length = %v", len(script)) + return + } + gapSize := len(script) - 39 + pubBlock := secp256k1.PubKeyBytesLenCompressed + 1 + if gapSize%pubBlock != 0 { + err = fmt.Errorf("invalid multisig payment length = %v", len(script)) + return + } + if script[0] == txscript.OP_IF && + // nRequired + // OP_DATA_33 and pubkey pairs + // nKeys + script[gapSize+3] == txscript.OP_CHECKMULTISIGVERIFY && + script[gapSize+4] == txscript.OP_1 && + script[gapSize+5] == txscript.OP_ELSE && + script[gapSize+6] == txscript.OP_DATA_4 && + // locktime + script[gapSize+11] == txscript.OP_CHECKLOCKTIMEVERIFY && + script[gapSize+12] == txscript.OP_DROP && + script[gapSize+13] == txscript.OP_DUP && + script[gapSize+14] == txscript.OP_HASH160 && + script[gapSize+15] == txscript.OP_DATA_20 && + // sender hash + script[gapSize+36] == txscript.OP_EQUALVERIFY && + script[gapSize+37] == txscript.OP_CHECKSIG && + script[gapSize+38] == txscript.OP_ENDIF { + nPubKeys := gapSize / pubBlock + if nPubKeys != int(script[gapSize+2])-txscript.OP_1+1 { + return 0, [ripemd160.Size]byte{}, 0, nil, errors.New("invalid nPubKeys") + } + pubKeys = make([]*secp256k1.PublicKey, nPubKeys) + for i := 0; i < nPubKeys; i++ { + if script[i*pubBlock+2] != txscript.OP_DATA_33 { + return 0, [ripemd160.Size]byte{}, 0, nil, errors.New("invalid multisig payment script") + } + start := i*pubBlock + 3 + pubKeys[i], err = secp256k1.ParsePubKey(script[start : start+secp256k1.PubKeyBytesLenCompressed]) + if err != nil { + return 0, [ripemd160.Size]byte{}, 0, nil, fmt.Errorf("bad pubkey: %v", err) + } + } + start := gapSize + 16 + copy(sender[:], script[start:start+ripemd160.Size]) + nRequired = int64(script[1]) - txscript.OP_1 + 1 + start = gapSize + 7 + locktime = int64(binary.LittleEndian.Uint32(script[start : start+4])) + } else { + err = errors.New("invalid multisig payment script") + } + return +} diff --git a/dex/networks/dcr/script_test.go b/dex/networks/dcr/script_test.go index 50bbe1aba6..b60ea70555 100644 --- a/dex/networks/dcr/script_test.go +++ b/dex/networks/dcr/script_test.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" "testing" + "time" "decred.org/dcrdex/dex" "github.com/decred/dcrd/chaincfg/chainhash" @@ -717,3 +718,219 @@ func TestIsRefundScript(t *testing.T) { }) } } + +func TestExtractPaymentMultisigDetails(t *testing.T) { + p2pkh, _ := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(randBytes(20), tParams) + pk1 := secp256k1.PrivKeyFromBytes(randBytes(32)).PubKey() + pk2 := secp256k1.PrivKeyFromBytes(randBytes(32)).PubKey() + pk3 := secp256k1.PrivKeyFromBytes(randBytes(32)).PubKey() + tests := []struct { + name string + nRequired int64 + locktime int64 + pubKeys []*secp256k1.PublicKey + }{{ + name: "ok 1 of 2", + nRequired: 1, + locktime: time.Now().Unix(), + pubKeys: []*secp256k1.PublicKey{pk1, pk2}, + }, { + name: "ok 2 of 2", + nRequired: 2, + locktime: time.Now().Add(time.Hour * 24).Unix(), + pubKeys: []*secp256k1.PublicKey{pk1, pk2}, + }, { + name: "ok 2 of 3", + nRequired: 2, + locktime: time.Now().Add(time.Hour * 48).Unix(), + pubKeys: []*secp256k1.PublicKey{pk1, pk2, pk3}, + }} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + redeemScript, err := MakePaymentMultisig(p2pkh.String(), test.pubKeys, test.nRequired, test.locktime, tParams) + if err != nil { + t.Fatal(err) + } + nRequired, sender, locktime, pubKeys, err := ExtractPaymentMultisigDetails(redeemScript) + if err != nil { + t.Fatal(err) + } + if nRequired != test.nRequired { + t.Fatalf("wanted nRequired %v but got %v", test.nRequired, nRequired) + } + hash := p2pkh.Hash160() + if sender != *hash { + t.Fatalf("wanted sender %v but got %v", p2pkh.Hash160(), sender) + } + if locktime != test.locktime { + t.Fatalf("wanted locktime %v but got %v", test.locktime, locktime) + } + if len(pubKeys) != len(test.pubKeys) { + t.Fatal("diff number of pubkeys") + } + for i, pubkey := range test.pubKeys { + if !bytes.Equal(pubkey.SerializeCompressed()[:], pubKeys[i].SerializeCompressed()[:]) { + t.Fatal("pubkeys not equal or in order") + } + } + }) + } +} + +func TestMakePaymentMultisigErrors(t *testing.T) { + p2pkh, _ := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(randBytes(20), tParams) + pk1 := secp256k1.PrivKeyFromBytes(randBytes(32)).PubKey() + pk2 := secp256k1.PrivKeyFromBytes(randBytes(32)).PubKey() + locktime := time.Now().Unix() + + // nRequired < 1 + _, err := MakePaymentMultisig(p2pkh.String(), []*secp256k1.PublicKey{pk1, pk2}, 0, locktime, tParams) + if err == nil { + t.Fatal("expected error for nRequired < 1") + } + + // nRequired > 16 + _, err = MakePaymentMultisig(p2pkh.String(), []*secp256k1.PublicKey{pk1, pk2}, 17, locktime, tParams) + if err == nil { + t.Fatal("expected error for nRequired > 16") + } + + // Less than 2 signers + _, err = MakePaymentMultisig(p2pkh.String(), []*secp256k1.PublicKey{pk1}, 1, locktime, tParams) + if err == nil { + t.Fatal("expected error for < 2 signers") + } + + // Invalid address + _, err = MakePaymentMultisig("invalid", []*secp256k1.PublicKey{pk1, pk2}, 1, locktime, tParams) + if err == nil { + t.Fatal("expected error for invalid address") + } +} + +func TestPaymentMultisigRedeemAndRefundScripts(t *testing.T) { + p2pkh, _ := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(randBytes(20), tParams) + priv1 := secp256k1.PrivKeyFromBytes(randBytes(32)) + priv2 := secp256k1.PrivKeyFromBytes(randBytes(32)) + pk1 := priv1.PubKey() + pk2 := priv2.PubKey() + locktime := time.Now().Unix() + + redeemScript, err := MakePaymentMultisig(p2pkh.String(), []*secp256k1.PublicKey{pk1, pk2}, 1, locktime, tParams) + if err != nil { + t.Fatalf("MakePaymentMultisig error: %v", err) + } + + // Test RedeemPaymentMultisig + fakeSig := randBytes(71) // typical DER signature length + sigScript, err := RedeemPaymentMultisig(redeemScript, [][]byte{fakeSig}) + if err != nil { + t.Fatalf("RedeemPaymentMultisig error: %v", err) + } + if len(sigScript) == 0 { + t.Fatal("expected non-empty sig script") + } + + // Test SigsFromPaymentMultisig + extractedScript, sigs, err := SigsFromPaymentMultisig(sigScript) + if err != nil { + t.Fatalf("SigsFromPaymentMultisig error: %v", err) + } + if !bytes.Equal(extractedScript, redeemScript) { + t.Fatal("extracted redeem script doesn't match original") + } + if len(sigs) != 1 { + t.Fatalf("expected 1 sig, got %d", len(sigs)) + } + if !bytes.Equal(sigs[0], fakeSig) { + t.Fatal("extracted sig doesn't match original") + } + + // Test RefundPaymentMultisig + fakePubKey := pk1.SerializeCompressed() + refundScript, err := RefundPaymentMultisig(redeemScript, fakePubKey, fakeSig) + if err != nil { + t.Fatalf("RefundPaymentMultisig error: %v", err) + } + if len(refundScript) == 0 { + t.Fatal("expected non-empty refund script") + } +} + +func TestPaymentMultisigTxSizes(t *testing.T) { + // Test that size calculations return reasonable values + tests := []struct { + name string + nPub int64 + nRequired int64 + nRecipients int64 + }{{ + name: "1 of 2, 1 recipient", + nPub: 2, + nRequired: 1, + nRecipients: 1, + }, { + name: "2 of 3, 2 recipients", + nPub: 3, + nRequired: 2, + nRecipients: 2, + }, { + name: "3 of 5, 3 recipients", + nPub: 5, + nRequired: 3, + nRecipients: 3, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + redeemSize := PaymentMultisigRedeemTxSize(test.nPub, test.nRequired, test.nRecipients) + if redeemSize == 0 { + t.Fatal("redeem tx size should not be 0") + } + // Redeem size should be greater than just the overhead + if redeemSize < MsgTxOverhead { + t.Fatalf("redeem size %d should be greater than overhead %d", redeemSize, MsgTxOverhead) + } + + refundSize := PaymentMultisigRefundTxSize(test.nPub) + if refundSize == 0 { + t.Fatal("refund tx size should not be 0") + } + if refundSize < MsgTxOverhead { + t.Fatalf("refund size %d should be greater than overhead %d", refundSize, MsgTxOverhead) + } + + // Refund should generally be smaller than redeem with multiple sigs + // (refund has 1 sig, redeem has nRequired sigs) + if test.nRequired > 1 && refundSize >= redeemSize { + t.Logf("Note: refund size %d >= redeem size %d (may be expected with few recipients)", refundSize, redeemSize) + } + }) + } +} + +func TestExtractPaymentMultisigDetailsErrors(t *testing.T) { + // Test with invalid/short scripts + _, _, _, _, err := ExtractPaymentMultisigDetails([]byte{}) + if err == nil { + t.Fatal("expected error for empty script") + } + + _, _, _, _, err = ExtractPaymentMultisigDetails(randBytes(10)) + if err == nil { + t.Fatal("expected error for short script") + } + + _, _, _, _, err = ExtractPaymentMultisigDetails(randBytes(50)) + if err == nil { + t.Fatal("expected error for invalid script") + } +} + +func TestSigsFromPaymentMultisigErrors(t *testing.T) { + // Test with empty script + _, _, err := SigsFromPaymentMultisig([]byte{}) + if err == nil { + t.Fatal("expected error for empty script") + } +}