mirror of
https://github.com/btcsuite/btcd.git
synced 2025-03-13 11:35:52 +01:00
Merge 8e879e1b39
into c7191d2913
This commit is contained in:
commit
e9a838b50b
8 changed files with 620 additions and 8 deletions
|
@ -14,6 +14,9 @@ package psbt
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||||
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
||||||
"github.com/btcsuite/btcd/txscript"
|
"github.com/btcsuite/btcd/txscript"
|
||||||
|
@ -47,11 +50,19 @@ func isFinalizableWitnessInput(pInput *PInput) bool {
|
||||||
|
|
||||||
case txscript.IsPayToTaproot(pkScript):
|
case txscript.IsPayToTaproot(pkScript):
|
||||||
if pInput.TaprootKeySpendSig == nil &&
|
if pInput.TaprootKeySpendSig == nil &&
|
||||||
pInput.TaprootScriptSpendSig == nil {
|
pInput.TaprootScriptSpendSig == nil &&
|
||||||
|
pInput.MuSig2PartialSigs == nil {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For each participant, we need a corresponding
|
||||||
|
// MuSig2 partial signature.
|
||||||
|
if len(pInput.MuSig2PartialSigs) > 0 {
|
||||||
|
return len(pInput.MuSig2PartialSigs) ==
|
||||||
|
len(pInput.MuSig2PubNonces)
|
||||||
|
}
|
||||||
|
|
||||||
// For each of the script spend signatures we need a
|
// For each of the script spend signatures we need a
|
||||||
// corresponding tap script leaf with the control block.
|
// corresponding tap script leaf with the control block.
|
||||||
for _, sig := range pInput.TaprootScriptSpendSig {
|
for _, sig := range pInput.TaprootScriptSpendSig {
|
||||||
|
@ -133,7 +144,8 @@ func isFinalizable(p *Packet, inIndex int) bool {
|
||||||
|
|
||||||
// The input cannot be finalized without any signatures.
|
// The input cannot be finalized without any signatures.
|
||||||
if pInput.PartialSigs == nil && pInput.TaprootKeySpendSig == nil &&
|
if pInput.PartialSigs == nil && pInput.TaprootKeySpendSig == nil &&
|
||||||
pInput.TaprootScriptSpendSig == nil {
|
pInput.TaprootScriptSpendSig == nil &&
|
||||||
|
pInput.MuSig2PartialSigs == nil {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -577,6 +589,91 @@ func finalizeTaprootInput(p *Packet, inIndex int) error {
|
||||||
|
|
||||||
serializedWitness, err = writeWitness(witnessStack...)
|
serializedWitness, err = writeWitness(witnessStack...)
|
||||||
|
|
||||||
|
// MuSig2 spend path.
|
||||||
|
case len(pInput.MuSig2PartialSigs) > 0:
|
||||||
|
if len(pInput.MuSig2PubNonces) !=
|
||||||
|
len(pInput.MuSig2PartialSigs) {
|
||||||
|
|
||||||
|
return fmt.Errorf("number of MuSig2 pub nonces " +
|
||||||
|
"does not match number of partial signatures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll need to combine MuSig2 partial signatures into a single
|
||||||
|
// one, which requires the message that was signed over.
|
||||||
|
firstSig := pInput.MuSig2PartialSigs[0]
|
||||||
|
|
||||||
|
// We don't (yet) support signing over a tap leaf hash.
|
||||||
|
// TODO(guggero): Add support for signing over a tap leaf hash.
|
||||||
|
if len(firstSig.TapLeafHash) > 0 {
|
||||||
|
return fmt.Errorf("combining partial MuSig2 " +
|
||||||
|
"signatures for a tap leaf is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
prevOutFetcher := PrevOutputFetcher(p)
|
||||||
|
sigHashes := txscript.NewTxSigHashes(
|
||||||
|
p.UnsignedTx, prevOutFetcher,
|
||||||
|
)
|
||||||
|
sigHash, err := txscript.CalcTaprootSignatureHash(
|
||||||
|
sigHashes, pInput.SighashType, p.UnsignedTx,
|
||||||
|
inIndex, prevOutFetcher,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error calculating signature hash: "+
|
||||||
|
"%w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sigHashMsg [32]byte
|
||||||
|
copy(sigHashMsg[:], sigHash)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pubNonces = make(
|
||||||
|
[][musig2.PubNonceSize]byte,
|
||||||
|
len(pInput.MuSig2PubNonces),
|
||||||
|
)
|
||||||
|
keys = make(
|
||||||
|
[]*btcec.PublicKey, len(pInput.MuSig2PubNonces),
|
||||||
|
)
|
||||||
|
partialSigs = make(
|
||||||
|
[]*musig2.PartialSignature,
|
||||||
|
len(pInput.MuSig2PartialSigs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for i, pubNonce := range pInput.MuSig2PubNonces {
|
||||||
|
copy(pubNonces[i][:], pubNonce.PubNonce[:])
|
||||||
|
keys[i] = pubNonce.PubKey
|
||||||
|
|
||||||
|
partialSigs[i] = &pInput.MuSig2PartialSigs[i].PartialSig
|
||||||
|
}
|
||||||
|
aggregateNonce, err := musig2.AggregateNonces(pubNonces)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error aggregating pub nonces: %w",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aggKey, _, _, err := musig2.AggregateKeys(
|
||||||
|
keys, true, musig2.WithBIP86KeyTweak(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error aggregating keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedNonce, err := computeSigningNonce(
|
||||||
|
aggregateNonce, aggKey.FinalKey, sigHashMsg,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error computing signing nonce: %w",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
|
||||||
|
combineOpt := musig2.WithBip86TweakedCombine(
|
||||||
|
sigHashMsg, keys, true,
|
||||||
|
)
|
||||||
|
schnorrSig := musig2.CombineSigs(
|
||||||
|
combinedNonce, partialSigs, combineOpt,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializedWitness, err = writeWitness(schnorrSig.Serialize())
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return ErrInvalidPsbtFormat
|
return ErrInvalidPsbtFormat
|
||||||
}
|
}
|
||||||
|
@ -595,3 +692,57 @@ func finalizeTaprootInput(p *Packet, inIndex int) error {
|
||||||
p.Inputs[inIndex] = *newInput
|
p.Inputs[inIndex] = *newInput
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeSigningNonce calculates the final nonce used for signing. This will
|
||||||
|
// be the R value used in the final signature.
|
||||||
|
func computeSigningNonce(combinedNonce [musig2.PubNonceSize]byte,
|
||||||
|
combinedKey *btcec.PublicKey, msg [32]byte) (*btcec.PublicKey, error) {
|
||||||
|
|
||||||
|
// Next we'll compute the value b, that blinds our second public
|
||||||
|
// nonce:
|
||||||
|
// * b = h(tag=NonceBlindTag, combinedNonce || combinedKey || m).
|
||||||
|
var (
|
||||||
|
nonceMsgBuf bytes.Buffer
|
||||||
|
nonceBlinder btcec.ModNScalar
|
||||||
|
)
|
||||||
|
nonceMsgBuf.Write(combinedNonce[:])
|
||||||
|
nonceMsgBuf.Write(schnorr.SerializePubKey(combinedKey))
|
||||||
|
nonceMsgBuf.Write(msg[:])
|
||||||
|
nonceBlindHash := chainhash.TaggedHash(
|
||||||
|
musig2.NonceBlindTag, nonceMsgBuf.Bytes(),
|
||||||
|
)
|
||||||
|
nonceBlinder.SetByteSlice(nonceBlindHash[:])
|
||||||
|
|
||||||
|
// Next, we'll parse the public nonces into R1 and R2.
|
||||||
|
r1J, err := btcec.ParseJacobian(
|
||||||
|
combinedNonce[:btcec.PubKeyBytesLenCompressed],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r2J, err := btcec.ParseJacobian(
|
||||||
|
combinedNonce[btcec.PubKeyBytesLenCompressed:],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// With our nonce blinding value, we'll now combine both the public
|
||||||
|
// nonces, using the blinding factor to tweak the second nonce:
|
||||||
|
// * R = R_1 + b*R_2
|
||||||
|
var nonce btcec.JacobianPoint
|
||||||
|
btcec.ScalarMultNonConst(&nonceBlinder, &r2J, &r2J)
|
||||||
|
btcec.AddNonConst(&r1J, &r2J, &nonce)
|
||||||
|
|
||||||
|
// If the combined nonce is the point at infinity, we'll use the
|
||||||
|
// generator point instead.
|
||||||
|
var infinityPoint btcec.JacobianPoint
|
||||||
|
if nonce == infinityPoint {
|
||||||
|
G := btcec.Generator()
|
||||||
|
G.AsJacobian(&nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce.ToAffine()
|
||||||
|
|
||||||
|
return btcec.NewPublicKey(&nonce.X, &nonce.Y), nil
|
||||||
|
}
|
||||||
|
|
|
@ -4,11 +4,11 @@ go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/btcsuite/btcd v0.23.5-0.20231219003633-4c2ce6daed8f
|
github.com/btcsuite/btcd v0.23.5-0.20231219003633-4c2ce6daed8f
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
github.com/btcsuite/btcd/btcec/v2 v2.3.3
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.4
|
github.com/btcsuite/btcd/btcutil v1.1.4
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
|
||||||
github.com/davecgh/go-spew v1.1.1
|
github.com/davecgh/go-spew v1.1.1
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
@ -18,5 +18,5 @@ require (
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
|
||||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed // indirect
|
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,8 +5,9 @@ github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6
|
||||||
github.com/btcsuite/btcd v0.23.5-0.20231219003633-4c2ce6daed8f h1:E+dQ8sNtK/lOdfeflUKkRLXe/zW7I333C7HhaoASjZA=
|
github.com/btcsuite/btcd v0.23.5-0.20231219003633-4c2ce6daed8f h1:E+dQ8sNtK/lOdfeflUKkRLXe/zW7I333C7HhaoASjZA=
|
||||||
github.com/btcsuite/btcd v0.23.5-0.20231219003633-4c2ce6daed8f/go.mod h1:KVEB81PybLGYzpf1db/kKNi1ZEbUsiVGeTGhKuOl5AM=
|
github.com/btcsuite/btcd v0.23.5-0.20231219003633-4c2ce6daed8f/go.mod h1:KVEB81PybLGYzpf1db/kKNi1ZEbUsiVGeTGhKuOl5AM=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE=
|
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.3.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0=
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.3.3/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.4 h1:mWvWRLRIPuoeZsVRpc0xNCkfeNxWy1E4jIZ06ZpGI1A=
|
github.com/btcsuite/btcd/btcutil v1.1.4 h1:mWvWRLRIPuoeZsVRpc0xNCkfeNxWy1E4jIZ06ZpGI1A=
|
||||||
|
@ -65,8 +66,11 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
@ -107,5 +111,6 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
282
btcutil/psbt/musig2.go
Normal file
282
btcutil/psbt/musig2.go
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
package psbt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MuSig2Participants represents a set of participants in a MuSig2 signing
|
||||||
|
// session.
|
||||||
|
type MuSig2Participants struct {
|
||||||
|
// AggregateKey is the plain (non-tweaked) aggregate public key of all
|
||||||
|
// participants, from the `KeyAgg` algorithm as described in the MuSig2
|
||||||
|
// BIP. This key may or may not be in the script directly (x-only). It
|
||||||
|
// may instead be a parent public key from which the public key in the
|
||||||
|
// script were derived.
|
||||||
|
AggregateKey *btcec.PublicKey
|
||||||
|
|
||||||
|
// Keys is a list of the public keys of the participants in the MuSig2
|
||||||
|
// aggregate key in the order required for aggregation. If sorting was
|
||||||
|
// done, then the keys must be in the sorted order.
|
||||||
|
Keys []*btcec.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyData returns the key data for the MuSig2Participants struct.
|
||||||
|
func (m *MuSig2Participants) KeyData() []byte {
|
||||||
|
return m.AggregateKey.SerializeCompressed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMuSig2Participants reads a set of MuSig2 participants from a key-value
|
||||||
|
// pair in a PSBT.
|
||||||
|
func ReadMuSig2Participants(keyData,
|
||||||
|
value []byte) (*MuSig2Participants, error) {
|
||||||
|
|
||||||
|
if len(keyData) != btcec.PubKeyBytesLenCompressed {
|
||||||
|
return nil, ErrInvalidKeyData
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value) == 0 || len(value)%btcec.PubKeyBytesLenCompressed != 0 {
|
||||||
|
return nil, ErrInvalidPsbtFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
numKeys := len(value) / btcec.PubKeyBytesLenCompressed
|
||||||
|
participants := &MuSig2Participants{
|
||||||
|
Keys: make([]*btcec.PublicKey, numKeys),
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
participants.AggregateKey, err = btcec.ParsePubKey(keyData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := 0; idx < numKeys; idx++ {
|
||||||
|
start := idx * btcec.PubKeyBytesLenCompressed
|
||||||
|
participants.Keys[idx], err = btcec.ParsePubKey(
|
||||||
|
value[start : start+btcec.PubKeyBytesLenCompressed],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return participants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeMuSig2Participants serializes a set of MuSig2 participants to a
|
||||||
|
// key-value pair in a PSBT.
|
||||||
|
func SerializeMuSig2Participants(w io.Writer, typ uint8,
|
||||||
|
participants *MuSig2Participants) error {
|
||||||
|
|
||||||
|
value := make(
|
||||||
|
[]byte, len(participants.Keys)*btcec.PubKeyBytesLenCompressed,
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, key := range participants.Keys {
|
||||||
|
copy(
|
||||||
|
value[idx*btcec.PubKeyBytesLenCompressed:],
|
||||||
|
key.SerializeCompressed(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeKVPairWithType(w, typ, participants.KeyData(), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuSig2PubNonce represents a public nonce provided by a participant in a
|
||||||
|
// MuSig2 signing session.
|
||||||
|
type MuSig2PubNonce struct {
|
||||||
|
// PubKey is the public key of the participant providing this nonce.
|
||||||
|
PubKey *btcec.PublicKey
|
||||||
|
|
||||||
|
// AggregateKey is the plain (non-tweaked) aggregate public key the
|
||||||
|
// participant is providing the nonce for. This must be the key found in
|
||||||
|
// the script and not the aggregate public key that it was derived from,
|
||||||
|
// if it was derived from an aggregate key.
|
||||||
|
AggregateKey *btcec.PublicKey
|
||||||
|
|
||||||
|
// TapLeafHash is the optional hash of the BIP-0341 tap leaf hash of the
|
||||||
|
// Taproot leaf script that will be signed. If the aggregate key is the
|
||||||
|
// taproot internal key or the taproot output key, then the tap leaf
|
||||||
|
// hash must be omitted.
|
||||||
|
TapLeafHash []byte
|
||||||
|
|
||||||
|
// PUbNonce is the public nonce provided by the participant, produced
|
||||||
|
// by the `NonceGen` algorithm as described in the MuSig2 BIP.
|
||||||
|
PubNonce [musig2.PubNonceSize]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyData returns the key data for the MuSig2PubNonce struct.
|
||||||
|
func (m *MuSig2PubNonce) KeyData() []byte {
|
||||||
|
// The tap leaf hash is optional.
|
||||||
|
keyLen := 2*btcec.PubKeyBytesLenCompressed + len(m.TapLeafHash)
|
||||||
|
keyData := make([]byte, keyLen)
|
||||||
|
|
||||||
|
copy(keyData, m.PubKey.SerializeCompressed())
|
||||||
|
copy(
|
||||||
|
keyData[btcec.PubKeyBytesLenCompressed:],
|
||||||
|
m.AggregateKey.SerializeCompressed(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(m.TapLeafHash) != 0 {
|
||||||
|
copy(keyData[2*btcec.PubKeyBytesLenCompressed:], m.TapLeafHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyData
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMuSig2PubNonce reads a MuSig2 public nonce from a key-value pair in a
|
||||||
|
// PSBT.
|
||||||
|
func ReadMuSig2PubNonce(keyData, value []byte) (*MuSig2PubNonce, error) {
|
||||||
|
const pubKeyLen = btcec.PubKeyBytesLenCompressed
|
||||||
|
const minLength = 2 * pubKeyLen
|
||||||
|
const maxLength = minLength + sha256.Size
|
||||||
|
|
||||||
|
if len(keyData) != minLength && len(keyData) != maxLength {
|
||||||
|
return nil, ErrInvalidKeyData
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value) != musig2.PubNonceSize {
|
||||||
|
return nil, ErrInvalidPsbtFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
nonce MuSig2PubNonce
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
nonce.PubKey, err = btcec.ParsePubKey(keyData[0:pubKeyLen])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce.AggregateKey, err = btcec.ParsePubKey(
|
||||||
|
keyData[pubKeyLen : 2*pubKeyLen],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keyData) == maxLength {
|
||||||
|
nonce.TapLeafHash = make([]byte, sha256.Size)
|
||||||
|
copy(nonce.TapLeafHash, keyData[2*pubKeyLen:])
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(nonce.PubNonce[:], value)
|
||||||
|
|
||||||
|
return &nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeMuSig2PubNonce serializes a MuSig2 public nonce to a key-value pair
|
||||||
|
// in a PSBT.
|
||||||
|
func SerializeMuSig2PubNonce(w io.Writer, typ uint8,
|
||||||
|
nonce *MuSig2PubNonce) error {
|
||||||
|
|
||||||
|
return serializeKVPairWithType(
|
||||||
|
w, typ, nonce.KeyData(), nonce.PubNonce[:],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuSig2PartialSig represents a partial signature provided by a participant in
|
||||||
|
// a MuSig2 signing session.
|
||||||
|
type MuSig2PartialSig struct {
|
||||||
|
// PubKey is the public key of the participant providing this nonce.
|
||||||
|
PubKey *btcec.PublicKey
|
||||||
|
|
||||||
|
// AggregateKey is the plain (non-tweaked) aggregate public key the
|
||||||
|
// participant is providing the nonce for. This must be the key found in
|
||||||
|
// the script and not the aggregate public key that it was derived from,
|
||||||
|
// if it was derived from an aggregate key.
|
||||||
|
AggregateKey *btcec.PublicKey
|
||||||
|
|
||||||
|
// TapLeafHash is the optional hash of the BIP-0341 tap leaf hash of the
|
||||||
|
// Taproot leaf script that will be signed. If the aggregate key is the
|
||||||
|
// taproot internal key or the taproot output key, then the tap leaf
|
||||||
|
// hash must be omitted.
|
||||||
|
TapLeafHash []byte
|
||||||
|
|
||||||
|
// PartialSig is the partial signature provided by the participant,
|
||||||
|
// produced by the `Sign` algorithm as described in the MuSig2 BIP.
|
||||||
|
PartialSig musig2.PartialSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyData returns the key data for the MuSig2PartialSig struct.
|
||||||
|
func (m *MuSig2PartialSig) KeyData() []byte {
|
||||||
|
// The tap leaf hash is optional.
|
||||||
|
keyLen := 2*btcec.PubKeyBytesLenCompressed + len(m.TapLeafHash)
|
||||||
|
keyData := make([]byte, keyLen)
|
||||||
|
|
||||||
|
copy(keyData, m.PubKey.SerializeCompressed())
|
||||||
|
copy(
|
||||||
|
keyData[btcec.PubKeyBytesLenCompressed:],
|
||||||
|
m.AggregateKey.SerializeCompressed(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(m.TapLeafHash) != 0 {
|
||||||
|
copy(keyData[2*btcec.PubKeyBytesLenCompressed:], m.TapLeafHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyData
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMuSig2PartialSig reads a MuSig2 partial signature from a key-value pair
|
||||||
|
// in a PSBT.
|
||||||
|
func ReadMuSig2PartialSig(keyData, value []byte) (*MuSig2PartialSig, error) {
|
||||||
|
const pubKeyLen = btcec.PubKeyBytesLenCompressed
|
||||||
|
const minLength = 2 * pubKeyLen
|
||||||
|
const maxLength = minLength + sha256.Size
|
||||||
|
|
||||||
|
if len(keyData) != minLength && len(keyData) != maxLength {
|
||||||
|
return nil, ErrInvalidKeyData
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value) != 32 {
|
||||||
|
return nil, ErrInvalidPsbtFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
partialSig MuSig2PartialSig
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
partialSig.PubKey, err = btcec.ParsePubKey(keyData[0:pubKeyLen])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
partialSig.AggregateKey, err = btcec.ParsePubKey(
|
||||||
|
keyData[pubKeyLen : 2*pubKeyLen],
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keyData) == maxLength {
|
||||||
|
partialSig.TapLeafHash = make([]byte, sha256.Size)
|
||||||
|
copy(partialSig.TapLeafHash, keyData[2*pubKeyLen:])
|
||||||
|
}
|
||||||
|
|
||||||
|
err = partialSig.PartialSig.Decode(bytes.NewReader(value))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &partialSig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeMuSig2PartialSig serializes a MuSig2 partial signature to a
|
||||||
|
// key-value pair in a PSBT.
|
||||||
|
func SerializeMuSig2PartialSig(w io.Writer, typ uint8,
|
||||||
|
partialSig *MuSig2PartialSig) error {
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := partialSig.PartialSig.Encode(&buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeKVPairWithType(
|
||||||
|
w, typ, partialSig.KeyData(), buf.Bytes(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -28,6 +28,9 @@ type PInput struct {
|
||||||
TaprootBip32Derivation []*TaprootBip32Derivation
|
TaprootBip32Derivation []*TaprootBip32Derivation
|
||||||
TaprootInternalKey []byte
|
TaprootInternalKey []byte
|
||||||
TaprootMerkleRoot []byte
|
TaprootMerkleRoot []byte
|
||||||
|
MuSig2Participants []*MuSig2Participants
|
||||||
|
MuSig2PubNonces []*MuSig2PubNonce
|
||||||
|
MuSig2PartialSigs []*MuSig2PartialSig
|
||||||
Unknowns []*Unknown
|
Unknowns []*Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -363,6 +366,60 @@ func (pi *PInput) deserialize(r io.Reader) error {
|
||||||
|
|
||||||
pi.TaprootMerkleRoot = value
|
pi.TaprootMerkleRoot = value
|
||||||
|
|
||||||
|
case MuSig2ParticipantsInputType:
|
||||||
|
participants, err := ReadMuSig2Participants(
|
||||||
|
keyData, value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate keys are not allowed.
|
||||||
|
newKey := participants.KeyData()
|
||||||
|
for _, x := range pi.MuSig2Participants {
|
||||||
|
if bytes.Equal(x.KeyData(), newKey) {
|
||||||
|
return ErrDuplicateKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.MuSig2Participants = append(
|
||||||
|
pi.MuSig2Participants, participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
case MuSig2PubNoncesInputType:
|
||||||
|
nonce, err := ReadMuSig2PubNonce(keyData, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate keys are not allowed.
|
||||||
|
newKey := nonce.KeyData()
|
||||||
|
for _, x := range pi.MuSig2PubNonces {
|
||||||
|
if bytes.Equal(x.KeyData(), newKey) {
|
||||||
|
return ErrDuplicateKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.MuSig2PubNonces = append(pi.MuSig2PubNonces, nonce)
|
||||||
|
|
||||||
|
case MuSig2PartialSigsInputType:
|
||||||
|
partialSig, err := ReadMuSig2PartialSig(keyData, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate keys are not allowed.
|
||||||
|
newKey := partialSig.KeyData()
|
||||||
|
for _, x := range pi.MuSig2PartialSigs {
|
||||||
|
if bytes.Equal(x.KeyData(), newKey) {
|
||||||
|
return ErrDuplicateKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.MuSig2PartialSigs = append(
|
||||||
|
pi.MuSig2PartialSigs, partialSig,
|
||||||
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// A fall through case for any proprietary types.
|
// A fall through case for any proprietary types.
|
||||||
keyCodeAndData := append(
|
keyCodeAndData := append(
|
||||||
|
@ -572,6 +629,36 @@ func (pi *PInput) serialize(w io.Writer) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, participants := range pi.MuSig2Participants {
|
||||||
|
err := SerializeMuSig2Participants(
|
||||||
|
w, uint8(MuSig2ParticipantsInputType),
|
||||||
|
participants,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nonce := range pi.MuSig2PubNonces {
|
||||||
|
err := SerializeMuSig2PubNonce(
|
||||||
|
w, uint8(MuSig2PubNoncesInputType),
|
||||||
|
nonce,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sig := range pi.MuSig2PartialSigs {
|
||||||
|
err := SerializeMuSig2PartialSig(
|
||||||
|
w, uint8(MuSig2PartialSigsInputType),
|
||||||
|
sig,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pi.FinalScriptSig != nil {
|
if pi.FinalScriptSig != nil {
|
||||||
|
|
|
@ -17,6 +17,7 @@ type POutput struct {
|
||||||
TaprootInternalKey []byte
|
TaprootInternalKey []byte
|
||||||
TaprootTapTree []byte
|
TaprootTapTree []byte
|
||||||
TaprootBip32Derivation []*TaprootBip32Derivation
|
TaprootBip32Derivation []*TaprootBip32Derivation
|
||||||
|
MuSig2Participants []*MuSig2Participants
|
||||||
Unknowns []*Unknown
|
Unknowns []*Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,6 +145,26 @@ func (po *POutput) deserialize(r io.Reader) error {
|
||||||
po.TaprootBip32Derivation, taprootDerivation,
|
po.TaprootBip32Derivation, taprootDerivation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case MuSig2ParticipantsOutputType:
|
||||||
|
participants, err := ReadMuSig2Participants(
|
||||||
|
keyData, value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate keys are not allowed.
|
||||||
|
newKey := participants.AggregateKey
|
||||||
|
for _, x := range po.MuSig2Participants {
|
||||||
|
if x.AggregateKey.IsEqual(newKey) {
|
||||||
|
return ErrDuplicateKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
po.MuSig2Participants = append(
|
||||||
|
po.MuSig2Participants, participants,
|
||||||
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// A fall through case for any proprietary types.
|
// A fall through case for any proprietary types.
|
||||||
keyCodeAndData := append(
|
keyCodeAndData := append(
|
||||||
|
@ -246,6 +267,15 @@ func (po *POutput) serialize(w io.Writer) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, participants := range po.MuSig2Participants {
|
||||||
|
err := SerializeMuSig2Participants(
|
||||||
|
w, uint8(MuSig2ParticipantsOutputType), participants,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown is a special case; we don't have a key type, only a key and
|
// Unknown is a special case; we don't have a key type, only a key and
|
||||||
// a value field
|
// a value field
|
||||||
for _, kv := range po.Unknowns {
|
for _, kv := range po.Unknowns {
|
||||||
|
|
|
@ -151,6 +151,24 @@ const (
|
||||||
// 32-byte hash denoting the root hash of a merkle tree of scripts.
|
// 32-byte hash denoting the root hash of a merkle tree of scripts.
|
||||||
TaprootMerkleRootType InputType = 0x18
|
TaprootMerkleRootType InputType = 0x18
|
||||||
|
|
||||||
|
// MuSig2ParticipantsInputType is a type that carries the participant
|
||||||
|
// public keys and aggregated key for a MuSig2 signing session
|
||||||
|
// ({0x1a}|{aggregate_key}). The value is a list of 33-byte compressed
|
||||||
|
// public keys in the order required for aggregation.
|
||||||
|
MuSig2ParticipantsInputType InputType = 0x1a
|
||||||
|
|
||||||
|
// MuSig2PubNoncesInputType is a type that carries the public nonces
|
||||||
|
// provided by participants in a MuSig2 signing session
|
||||||
|
// ({0x1b}|{participant_key}|{aggregate_key}[|{tapleaf_hash}]). The
|
||||||
|
// value is the 66-byte public nonces provided by the participant.
|
||||||
|
MuSig2PubNoncesInputType InputType = 0x1b
|
||||||
|
|
||||||
|
// MuSig2PartialSigsInputType is a type that carries the partial
|
||||||
|
// signatures provided by participants in a MuSig2 signing session
|
||||||
|
// ({0x1c}|{participant_key}|{aggregate_key}[|{tapleaf_hash}]). The
|
||||||
|
// value is the 32-byte partial signature provided by the participant.
|
||||||
|
MuSig2PartialSigsInputType InputType = 0x1c
|
||||||
|
|
||||||
// ProprietaryInputType is a custom type for use by devs.
|
// ProprietaryInputType is a custom type for use by devs.
|
||||||
//
|
//
|
||||||
// The key ({0xFC}|<prefix>|{subtype}|{key data}), is a Variable length
|
// The key ({0xFC}|<prefix>|{subtype}|{key data}), is a Variable length
|
||||||
|
@ -200,4 +218,10 @@ const (
|
||||||
// followed by said number of 32-byte leaf hashes. The rest of the value
|
// followed by said number of 32-byte leaf hashes. The rest of the value
|
||||||
// is then identical to the Bip32DerivationInputType value.
|
// is then identical to the Bip32DerivationInputType value.
|
||||||
TaprootBip32DerivationOutputType OutputType = 7
|
TaprootBip32DerivationOutputType OutputType = 7
|
||||||
|
|
||||||
|
// MuSig2ParticipantsOutputType is a type that carries the participant
|
||||||
|
// public keys and aggregated key for a MuSig2 signing session
|
||||||
|
// ({0x08}|{aggregate_key}). The value is a list of 33-byte compressed
|
||||||
|
// public keys in the order required for aggregation.
|
||||||
|
MuSig2ParticipantsOutputType OutputType = 0x08
|
||||||
)
|
)
|
||||||
|
|
|
@ -478,3 +478,36 @@ func FindLeafScript(pInput *PInput,
|
||||||
return nil, fmt.Errorf("leaf script for target leaf hash %x not "+
|
return nil, fmt.Errorf("leaf script for target leaf hash %x not "+
|
||||||
"found in input", targetLeafHash)
|
"found in input", targetLeafHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrevOutputFetcher returns a txscript.PrevOutFetcher built from the UTXO
|
||||||
|
// information in a PSBT packet.
|
||||||
|
func PrevOutputFetcher(packet *Packet) *txscript.MultiPrevOutFetcher {
|
||||||
|
fetcher := txscript.NewMultiPrevOutFetcher(nil)
|
||||||
|
for idx, txIn := range packet.UnsignedTx.TxIn {
|
||||||
|
in := packet.Inputs[idx]
|
||||||
|
|
||||||
|
// Skip any input that has no UTXO.
|
||||||
|
if in.WitnessUtxo == nil && in.NonWitnessUtxo == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.NonWitnessUtxo != nil {
|
||||||
|
prevIndex := txIn.PreviousOutPoint.Index
|
||||||
|
fetcher.AddPrevOut(
|
||||||
|
txIn.PreviousOutPoint,
|
||||||
|
in.NonWitnessUtxo.TxOut[prevIndex],
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to witness UTXO only for older wallets.
|
||||||
|
if in.WitnessUtxo != nil {
|
||||||
|
fetcher.AddPrevOut(
|
||||||
|
txIn.PreviousOutPoint, in.WitnessUtxo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetcher
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue