diff --git a/.gitignore b/.gitignore index 08c8a39..7698f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node2 config.yaml .vscode .vagrant +wallets.json \ No newline at end of file diff --git a/INSTALLATION.md b/INSTALLATION.md index c13e662..00e82ae 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -168,6 +168,8 @@ Update `config.yaml`: event_initiator_pubkey: "09be5d070816aadaa1b6638cad33e819a8aed7101626f6bf1e0b427412c3408a" ``` +> 💡 **Note**: If you plan to use the presign pool worker (see [Presign Pool Worker](#presign-pool-worker) section), you'll need the `event_initiator.key` file (or `event_initiator.key.age` if encrypted) to be available. The private key file is generated alongside the identity file. + --- ## Configure Node Identities @@ -274,6 +276,49 @@ mpcium start -n node2 --- +## Presign Pool Worker + +The presign pool worker automatically maintains a pool of presignatures for hot wallets, ensuring they're ready for immediate use. + +### Setup + +To enable the presign pool worker on a node: + +1. **Copy the event initiator private key** to the node directory: + + If you generated the initiator with encryption: + ```bash + # Decrypt the key first + age --decrypt -o event_initiator.key event_initiator.key.age + ``` + + Then copy it to the node directory: + ```bash + cp event_initiator.key node0/ + ``` + + If you generated the initiator without encryption: + ```bash + cp event_initiator.key node0/ + ``` + +2. **Start the node with the `--presign-pool-worker` flag**: + + ```bash + cd node0 + mpcium start -n node0 --presign-pool-worker + ``` + +> ⚠️ **Important**: Only one node in the cluster should run the presign pool worker. The node running this worker must have the `event_initiator.key` file in its working directory. + +### How It Works + +- The worker monitors hot wallet activity and automatically generates presignatures when needed +- It maintains a pool of presignatures between `MinPoolSize` (default: 5) and `MaxPoolSize` (default: 20) +- The worker subscribes to hot wallet events and proactively refills the presignature pool + +--- + ## Production Deployment (High Security) 1. Use production-grade **NATS** and **Consul** clusters. diff --git a/cmd/mpcium/main.go b/cmd/mpcium/main.go index a68eb0d..ef8385a 100644 --- a/cmd/mpcium/main.go +++ b/cmd/mpcium/main.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/fystack/mpcium/pkg/client" "github.com/fystack/mpcium/pkg/config" "github.com/fystack/mpcium/pkg/constant" "github.com/fystack/mpcium/pkg/event" @@ -22,7 +23,10 @@ import ( "github.com/fystack/mpcium/pkg/logger" "github.com/fystack/mpcium/pkg/messaging" "github.com/fystack/mpcium/pkg/mpc" + "github.com/fystack/mpcium/pkg/presign" + "github.com/fystack/mpcium/pkg/presigninfo" "github.com/fystack/mpcium/pkg/security" + "github.com/fystack/mpcium/pkg/types" "github.com/hashicorp/consul/api" "github.com/nats-io/nats.go" "github.com/spf13/viper" @@ -77,6 +81,11 @@ func main() { Aliases: []string{"k"}, Usage: "Path to file containing password for decrypting .age encrypted node private key", }, + &cli.BoolFlag{ + Name: "presign-pool-worker", + Usage: "Enable presign pool worker", + Value: false, + }, &cli.BoolFlag{ Name: "debug", Usage: "Enable debug logging", @@ -109,6 +118,7 @@ func runNode(ctx context.Context, c *cli.Command) error { usePrompts := c.Bool("prompt-credentials") passwordFile := c.String("password-file") agePasswordFile := c.String("identity-password-file") + presignPoolWorker := c.Bool("presign-pool-worker") debug := c.Bool("debug") viper.SetDefault("backup_enabled", true) @@ -177,6 +187,7 @@ func runNode(ctx context.Context, c *cli.Command) error { "mpc.mpc_keygen_result.*", event.SigningResultTopic, "mpc.mpc_reshare_result.*", + event.PresignResultTopic, }, natsConn) genKeyResultQueue := mqManager.NewMessageQueue("mpc_keygen_result") @@ -185,11 +196,14 @@ func runNode(ctx context.Context, c *cli.Command) error { defer singingResultQueue.Close() reshareResultQueue := mqManager.NewMessageQueue("mpc_reshare_result") defer reshareResultQueue.Close() + presignResultQueue := mqManager.NewMessageQueue("mpc_presign_result") + defer presignResultQueue.Close() logger.Info("Node is running", "ID", nodeID, "name", nodeName) peerNodeIDs := GetPeerIDs(peers) peerRegistry := mpc.NewRegistry(nodeID, peerNodeIDs, consulClient.KV(), directMessaging, pubsub, identityStore) + presignInfoStore := presigninfo.NewStore(consulClient.KV()) mpcNode := mpc.NewNode( nodeID, @@ -198,6 +212,7 @@ func runNode(ctx context.Context, c *cli.Command) error { directMessaging, badgerKV, keyinfoStore, + presignInfoStore, peerRegistry, identityStore, ) @@ -209,6 +224,7 @@ func runNode(ctx context.Context, c *cli.Command) error { genKeyResultQueue, singingResultQueue, reshareResultQueue, + presignResultQueue, identityStore, ) eventConsumer.Run() @@ -289,6 +305,37 @@ func runNode(ctx context.Context, c *cli.Command) error { logger.Info("All consumers have finished") close(errChan) }() + + // Start presign pool worker before entering the blocking error loop + if presignPoolWorker { + presignPoolCtx, presignPoolCancel := context.WithCancel(appContext) + defer presignPoolCancel() + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyTypeEd25519, client.LocalSignerOptions{ + KeyPath: "./event_initiator.key", + }) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + mpcClient := client.NewMPCClient(client.Options{ + NatsConn: natsConn, + Signer: localSigner, + }) + presignPool := presign.NewPresignPool(nil, mpcClient, presignInfoStore) + + _, err = pubsub.Subscribe(eventconsumer.MPCHotWalletEvent, func(nm *nats.Msg) { + walletID := string(nm.Data) + if walletID != "" { + presignPool.TouchHot(walletID) + } + }) + if err != nil { + logger.Warn("Failed to subscribe to hot wallet events", "err", err) + } + + presignPool.Start(presignPoolCtx) + defer presignPool.Stop() + } + for err := range errChan { if err != nil { logger.Error("Consumer error received", err) @@ -296,7 +343,6 @@ func runNode(ctx context.Context, c *cli.Command) error { return err } } - return nil } diff --git a/e2e/go.mod b/e2e/go.mod index 4112bb3..82c3de4 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -1,12 +1,12 @@ module github.com/fystack/mpcium/e2e -go 1.23.0 +go 1.23.8 require ( github.com/dgraph-io/badger/v4 v4.7.0 github.com/fystack/mpcium v0.0.0-00010101000000-000000000000 github.com/google/uuid v1.6.0 - github.com/hashicorp/consul/api v1.26.1 + github.com/hashicorp/consul/api v1.32.1 github.com/nats-io/nats.go v1.31.0 github.com/stretchr/testify v1.10.0 gopkg.in/yaml.v2 v2.4.0 @@ -37,13 +37,15 @@ require ( github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcutil v1.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cronokirby/saferith v0.33.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.4.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -61,6 +63,7 @@ require ( github.com/ipfs/go-log v1.0.5 // indirect github.com/ipfs/go-log/v2 v2.1.3 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -83,6 +86,9 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/viper v1.18.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/taurusgroup/multi-party-sig v0.7.0-alpha-2025-01-28 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/blake3 v0.2.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect @@ -91,7 +97,7 @@ require ( go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.21.0 // indirect golang.org/x/crypto v0.37.0 // indirect - golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.31.0 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 804a2c4..cac7878 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -88,6 +88,8 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo= +github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -96,8 +98,9 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y= github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA= @@ -118,6 +121,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= +github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -158,10 +163,10 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= -github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= -github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= -github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= +github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE= +github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4= +github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -220,6 +225,9 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -361,12 +369,22 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/taurusgroup/multi-party-sig v0.7.0-alpha-2025-01-28 h1:rbyJpV3kH/aMxG7gUQ5ynveAEXuPiIG136Ld3HGNV7I= +github.com/taurusgroup/multi-party-sig v0.7.0-alpha-2025-01-28/go.mod h1:roZI3gaKCo15PUSB4LdJpTLTjq8TFsJiOH5kpcN1HpQ= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= +github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= @@ -402,8 +420,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o= -golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -440,6 +458,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/examples/generate/kms/main.go b/examples/generate/kms/main.go index 9bf4025..a27d54f 100644 --- a/examples/generate/kms/main.go +++ b/examples/generate/kms/main.go @@ -117,7 +117,11 @@ func main() { for _, walletID := range walletIDs { wg.Add(1) // Add to WaitGroup BEFORE attempting to create wallet - if err := mpcClient.CreateWallet(walletID); err != nil { + if err := mpcClient.CreateWallet(&types.GenerateKeyMessage{ + WalletID: walletID, + ECDSAProtocol: types.ProtocolCGGMP21, + EdDSAProtocol: types.ProtocolGG18, + }); err != nil { logger.Error("CreateWallet failed", err) walletStartTimes.Delete(walletID) wg.Done() // Now this is safe since we added 1 above diff --git a/examples/generate/main.go b/examples/generate/main.go index 3f0135f..5e263c7 100644 --- a/examples/generate/main.go +++ b/examples/generate/main.go @@ -92,9 +92,19 @@ func main() { walletIDsMu.Unlock() } + // Track processed results to prevent duplicate processing + processedResults := sync.Map{} + // STEP 2: Register the result handler AFTER all walletIDs are stored err = mpcClient.OnWalletCreationResult(func(event event.KeygenResultEvent) { logger.Info("Received wallet creation result", "event", event) + + // Check if we've already processed this result + if _, alreadyProcessed := processedResults.LoadOrStore(event.WalletID, true); alreadyProcessed { + logger.Warn("Duplicate wallet result received, ignoring", "walletID", event.WalletID) + return + } + now := time.Now() startTimeAny, ok := walletStartTimes.Load(event.WalletID) if ok { @@ -124,9 +134,15 @@ func main() { for _, walletID := range walletIDs { wg.Add(1) // Add to WaitGroup BEFORE attempting to create wallet - if err := mpcClient.CreateWallet(walletID); err != nil { + if err := mpcClient.CreateWallet(&types.GenerateKeyMessage{ + WalletID: walletID, + ECDSAProtocol: types.ProtocolCGGMP21, + EdDSAProtocol: types.ProtocolGG18, + }); err != nil { logger.Error("CreateWallet failed", err) walletStartTimes.Delete(walletID) + // Mark this wallet as processed to prevent callback from processing it + processedResults.Store(walletID, true) wg.Done() // Now this is safe since we added 1 above continue } diff --git a/examples/presign/main.go b/examples/presign/main.go new file mode 100644 index 0000000..77de2e9 --- /dev/null +++ b/examples/presign/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "slices" + "syscall" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +func main() { + const environment = "dev" + config.InitViperConfig("") + logger.Init(environment, true) + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + + // Validate algorithm + if !slices.Contains( + []string{ + string(types.EventInitiatorKeyTypeEd25519), + string(types.EventInitiatorKeyTypeP256), + }, + algorithm, + ) { + logger.Fatal( + fmt.Sprintf( + "invalid algorithm: %s. Must be %s or %s", + algorithm, + types.EventInitiatorKeyTypeEd25519, + types.EventInitiatorKeyTypeP256, + ), + nil, + ) + } + natsURL := viper.GetString("nats.url") + natsConn, err := nats.Connect(natsURL) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + defer natsConn.Close() + + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{ + KeyPath: "./event_initiator.key", + }) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + + mpcClient := client.NewMPCClient(client.Options{ + NatsConn: natsConn, + Signer: localSigner, + }) + + txMsg := &types.PresignTxMessage{ + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolCGGMP21, + WalletID: "196c6858-30de-4a49-9134-8bc825d40764", // Use the generated wallet ID + TxID: uuid.New().String(), + } + err = mpcClient.PresignTransaction(txMsg) + if err != nil { + logger.Fatal("PresignTransaction failed", err) + } + fmt.Printf("PresignTransaction(%q) sent, awaiting result...\n", txMsg.WalletID) + + // 3) Listen for signing results + err = mpcClient.OnPresignResult(func(evt event.PresignResultEvent) { + logger.Info("Presign result received", + "walletID", evt.WalletID, + "status", evt.Status, + ) + }) + if err != nil { + logger.Fatal("Failed to subscribe to OnPresignResult", err) + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + fmt.Println("Shutting down.") +} diff --git a/examples/reshare/main.go b/examples/reshare/main.go index 47c4d85..03f6c5b 100644 --- a/examples/reshare/main.go +++ b/examples/reshare/main.go @@ -88,6 +88,7 @@ func main() { NewThreshold: 1, // t+1 <= len(NodeIDs) KeyType: types.KeyTypeEd25519, + Protocol: types.ProtocolFROST, } err = mpcClient.Resharing(resharingMsg) if err != nil { diff --git a/examples/sign/main.go b/examples/sign/main.go index 3424610..fc9f593 100644 --- a/examples/sign/main.go +++ b/examples/sign/main.go @@ -70,8 +70,9 @@ func main() { dummyTx := []byte("deadbeef") // replace with real transaction bytes txMsg := &types.SignTxMessage{ - KeyType: types.KeyTypeEd25519, - WalletID: "ad24f678-b04b-4149-bcf6-bf9c90df8e63", // Use the generated wallet ID + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolCGGMP21, + WalletID: "7ae6ae1c-7663-4dc4-b982-33fb0a3602c3", // Use the generated wallet ID NetworkInternalCode: "solana-devnet", TxID: txID, Tx: dummyTx, @@ -87,6 +88,8 @@ func main() { logger.Info("Signing result received", "txID", evt.TxID, "signature", fmt.Sprintf("%x", evt.Signature), + "error", evt.ErrorReason, + "errorCode", evt.ErrorCode, ) }) if err != nil { diff --git a/go.mod b/go.mod index d9cc99b..8f858c9 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,10 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.8 github.com/aws/aws-sdk-go-v2/service/kms v1.45.0 github.com/bnb-chain/tss-lib/v2 v2.0.2 + github.com/btcsuite/btcd/btcec/v2 v2.3.2 github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 github.com/dgraph-io/badger/v4 v4.7.0 + github.com/fxamacker/cbor/v2 v2.4.0 github.com/google/uuid v1.6.0 github.com/hashicorp/consul/api v1.32.1 github.com/mitchellh/mapstructure v1.5.0 @@ -21,8 +23,10 @@ require ( github.com/samber/lo v1.39.0 github.com/spf13/viper v1.18.0 github.com/stretchr/testify v1.10.0 + github.com/taurusgroup/multi-party-sig v0.7.0-alpha-2025-01-28 github.com/urfave/cli/v3 v3.3.2 golang.org/x/crypto v0.37.0 + golang.org/x/sync v0.13.0 golang.org/x/term v0.31.0 ) @@ -41,12 +45,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.38.1 // indirect github.com/aws/smithy-go v1.23.0 // indirect github.com/btcsuite/btcd v0.24.2 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcutil v1.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cronokirby/saferith v0.33.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -68,6 +72,7 @@ require ( github.com/ipfs/go-log v1.0.5 // indirect github.com/ipfs/go-log/v2 v2.1.3 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -88,6 +93,8 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/blake3 v0.2.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect diff --git a/go.sum b/go.sum index da2768e..df0a58f 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo= +github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -96,8 +98,9 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y= github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA= @@ -118,6 +121,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= +github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -158,13 +163,10 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= -github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE= github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4= -github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= -github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -223,6 +225,9 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -364,14 +369,24 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/taurusgroup/multi-party-sig v0.7.0-alpha-2025-01-28 h1:rbyJpV3kH/aMxG7gUQ5ynveAEXuPiIG136Ld3HGNV7I= +github.com/taurusgroup/multi-party-sig v0.7.0-alpha-2025-01-28/go.mod h1:roZI3gaKCo15PUSB4LdJpTLTjq8TFsJiOH5kpcN1HpQ= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= +github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= @@ -407,8 +422,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -447,6 +460,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/peers.json b/peers.json new file mode 100644 index 0000000..0670265 --- /dev/null +++ b/peers.json @@ -0,0 +1,5 @@ +{ + "node0": "aa4adaea-257d-4337-842a-1d3f966d85c2", + "node1": "21ac5259-ac9e-4b81-bd42-d05f584879e4", + "node2": "2fff5119-a1f1-4763-8f4c-d7d88c212608" +} \ No newline at end of file diff --git a/pkg/client/client.go b/pkg/client/client.go index 3121bdb..2467091 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -19,7 +19,7 @@ const ( ) type MPCClient interface { - CreateWallet(walletID string) error + CreateWallet(msg *types.GenerateKeyMessage) error OnWalletCreationResult(callback func(event event.KeygenResultEvent)) error SignTransaction(msg *types.SignTxMessage) error @@ -27,6 +27,9 @@ type MPCClient interface { Resharing(msg *types.ResharingMessage) error OnResharingResult(callback func(event event.ResharingResultEvent)) error + + PresignTransaction(msg *types.PresignTxMessage) error + OnPresignResult(callback func(event event.PresignResultEvent)) error } type mpcClient struct { @@ -36,6 +39,7 @@ type mpcClient struct { genKeySuccessQueue messaging.MessageQueue signResultQueue messaging.MessageQueue reshareSuccessQueue messaging.MessageQueue + presignSuccessQueue messaging.MessageQueue signer Signer } @@ -85,11 +89,13 @@ func NewMPCClient(opts Options) MPCClient { "mpc.mpc_keygen_result.*", "mpc.mpc_signing_result.*", "mpc.mpc_reshare_result.*", + "mpc.mpc_presign_result.*", }, opts.NatsConn) genKeySuccessQueue := manager.NewMessageQueue("mpc_keygen_result") signResultQueue := manager.NewMessageQueue("mpc_signing_result") reshareSuccessQueue := manager.NewMessageQueue("mpc_reshare_result") + presignSuccessQueue := manager.NewMessageQueue("mpc_presign_result") return &mpcClient{ signingBroker: signingBroker, @@ -98,16 +104,13 @@ func NewMPCClient(opts Options) MPCClient { genKeySuccessQueue: genKeySuccessQueue, signResultQueue: signResultQueue, reshareSuccessQueue: reshareSuccessQueue, + presignSuccessQueue: presignSuccessQueue, signer: opts.Signer, } } // CreateWallet generates a GenerateKeyMessage, signs it, and publishes it. -func (c *mpcClient) CreateWallet(walletID string) error { - // build the message - msg := &types.GenerateKeyMessage{ - WalletID: walletID, - } +func (c *mpcClient) CreateWallet(msg *types.GenerateKeyMessage) error { // compute the canonical raw bytes raw, err := msg.Raw() if err != nil { @@ -235,3 +238,44 @@ func (c *mpcClient) OnResharingResult(callback func(event event.ResharingResultE return nil } + +func (c *mpcClient) PresignTransaction(msg *types.PresignTxMessage) error { + // compute the canonical raw bytes + raw, err := msg.Raw() + if err != nil { + return fmt.Errorf("PresignTransaction: raw payload error: %w", err) + } + signature, err := c.signer.Sign(raw) + if err != nil { + return fmt.Errorf("PresignTransaction: failed to sign message: %w", err) + } + msg.Signature = signature + + bytes, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("PresignTransaction: marshal error: %w", err) + } + + if err := c.pubsub.Publish(eventconsumer.MPCPresignEvent, bytes); err != nil { + return fmt.Errorf("PresignTransaction: publish error: %w", err) + } + return nil +} + +func (c *mpcClient) OnPresignResult(callback func(event event.PresignResultEvent)) error { + err := c.presignSuccessQueue.Dequeue(event.PresignResultTopic, func(msg []byte) error { + var event event.PresignResultEvent + err := json.Unmarshal(msg, &event) + if err != nil { + return err + } + callback(event) + return nil + }) + + if err != nil { + return fmt.Errorf("OnPresignResult: subscribe error: %w", err) + } + + return nil +} diff --git a/pkg/encoding/json.go b/pkg/encoding/json.go new file mode 100644 index 0000000..87ad4a3 --- /dev/null +++ b/pkg/encoding/json.go @@ -0,0 +1,13 @@ +package encoding + +import "encoding/json" + +// StructToJsonBytes converts a struct to JSON bytes +func StructToJsonBytes(v any) ([]byte, error) { + return json.Marshal(v) +} + +// JsonBytesToStruct converts JSON bytes to a struct +func JsonBytesToStruct(data []byte, v any) error { + return json.Unmarshal(data, v) +} diff --git a/pkg/event/keygen.go b/pkg/event/keygen.go index 78ab631..6e12da5 100644 --- a/pkg/event/keygen.go +++ b/pkg/event/keygen.go @@ -15,3 +15,17 @@ type KeygenResultEvent struct { ErrorReason string `json:"error_reason"` ErrorCode string `json:"error_code"` } + +// CreateKeygenFailureEvent creates a failed keygen event +func CreateKeygenFailureEvent(walletID string, metadata map[string]any) *KeygenResultEvent { + errorMsg := "" + if err, ok := metadata["error"].(string); ok { + errorMsg = err + } + return &KeygenResultEvent{ + WalletID: walletID, + ResultType: ResultTypeError, + ErrorReason: errorMsg, + ErrorCode: string(ErrorCodeKeygenFailure), + } +} diff --git a/pkg/event/presign.go b/pkg/event/presign.go new file mode 100644 index 0000000..7f5c0c0 --- /dev/null +++ b/pkg/event/presign.go @@ -0,0 +1,18 @@ +package event + +const ( + PresignBrokerStream = "mpc-presign" + PresignConsumerStream = "mpc-presign-consumer" + PresignRequestTopic = "mpc.presign_request.*" + PresignResultTopic = "mpc.mpc_presign_result.*" +) + +type PresignResultEvent struct { + ResultType ResultType `json:"result_type"` + ErrorCode ErrorCode `json:"error_code"` + ErrorReason string `json:"error_reason"` + IsTimeout bool `json:"is_timeout"` + WalletID string `json:"wallet_id"` + TxID string `json:"tx_id"` + Status string `json:"status"` +} diff --git a/pkg/eventconsumer/event_consumer.go b/pkg/eventconsumer/event_consumer.go index 691c712..8e54a15 100644 --- a/pkg/eventconsumer/event_consumer.go +++ b/pkg/eventconsumer/event_consumer.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "log" - "math/big" "sync" "time" @@ -15,6 +14,7 @@ import ( "github.com/fystack/mpcium/pkg/logger" "github.com/fystack/mpcium/pkg/messaging" "github.com/fystack/mpcium/pkg/mpc" + "github.com/fystack/mpcium/pkg/mpc/taurus" "github.com/fystack/mpcium/pkg/types" "github.com/nats-io/nats.go" "github.com/spf13/viper" @@ -24,6 +24,10 @@ const ( MPCGenerateEvent = "mpc:generate" MPCSignEvent = "mpc:sign" MPCReshareEvent = "mpc:reshare" + MPCPresignEvent = "mpc:presign" + + // Internal event to notify presign pool of a hot wallet + MPCHotWalletEvent = "mpc:wallet_hot" DefaultConcurrentKeygen = 2 DefaultConcurrentSigning = 20 @@ -45,10 +49,12 @@ type eventConsumer struct { genKeyResultQueue messaging.MessageQueue signingResultQueue messaging.MessageQueue reshareResultQueue messaging.MessageQueue + presignResultQueue messaging.MessageQueue keyGenerationSub messaging.Subscription signingSub messaging.Subscription reshareSub messaging.Subscription + presignSub messaging.Subscription identityStore identity.Store keygenMsgBuffer chan *nats.Msg @@ -63,6 +69,12 @@ type eventConsumer struct { cleanupInterval time.Duration // How often to run cleanup sessionTimeout time.Duration // How long before a session is considered stale cleanupStopChan chan struct{} // Signal to stop cleanup goroutine + + // Track recent signing activity to detect hot wallets + hotMu sync.Mutex + recentSigns map[string][]time.Time // key: walletID|keyType|protocol → timestamps within window + hotWindow time.Duration // window for counting signs (e.g., 5 minutes) + hotThreshold int // signs needed to mark as hot } func NewEventConsumer( @@ -71,6 +83,7 @@ func NewEventConsumer( genKeyResultQueue messaging.MessageQueue, signingResultQueue messaging.MessageQueue, reshareResultQueue messaging.MessageQueue, + presignResultQueue messaging.MessageQueue, identityStore identity.Store, ) EventConsumer { maxConcurrentKeygen := viper.GetInt("max_concurrent_keygen") @@ -104,6 +117,7 @@ func NewEventConsumer( genKeyResultQueue: genKeyResultQueue, signingResultQueue: signingResultQueue, reshareResultQueue: reshareResultQueue, + presignResultQueue: presignResultQueue, activeSessions: make(map[string]time.Time), cleanupInterval: 5 * time.Minute, // Run cleanup every 5 minutes sessionTimeout: 30 * time.Minute, // Consider sessions older than 30 minutes stale @@ -115,6 +129,9 @@ func NewEventConsumer( keygenMsgBuffer: make(chan *nats.Msg, 100), signingMsgBuffer: make(chan *nats.Msg, 200), // Larger buffer for signing sessionWarmUpDelayMs: sessionWarmUpDelayMs, + recentSigns: make(map[string][]time.Time), + hotWindow: 5 * time.Minute, + hotThreshold: 2, } go ec.startKeyGenEventWorker() @@ -141,6 +158,11 @@ func (ec *eventConsumer) Run() { log.Fatal("Failed to consume reshare event", err) } + err = ec.consumePresignEvent() + if err != nil { + log.Fatal("Failed to consume presign event", err) + } + logger.Info("MPC Event consumer started...!") } @@ -152,98 +174,104 @@ func (ec *eventConsumer) handleKeyGenEvent(natMsg *nats.Msg) { baseCtx, baseCancel := context.WithTimeout(context.Background(), KeyGenTimeOut) defer baseCancel() - raw := natMsg.Data var msg types.GenerateKeyMessage - if err := json.Unmarshal(raw, &msg); err != nil { - logger.Error("Failed to unmarshal keygen message", err) + if err := json.Unmarshal(natMsg.Data, &msg); err != nil { ec.handleKeygenSessionError(msg.WalletID, err, "Failed to unmarshal keygen message", natMsg) return } - if err := ec.identityStore.VerifyInitiatorMessage(&msg); err != nil { - logger.Error("Failed to verify initiator message", err) ec.handleKeygenSessionError(msg.WalletID, err, "Failed to verify initiator message", natMsg) return } - walletID := msg.WalletID - ecdsaSession, err := ec.node.CreateKeyGenSession(mpc.SessionTypeECDSA, walletID, ec.mpcThreshold, ec.genKeyResultQueue) - if err != nil { - ec.handleKeygenSessionError(walletID, err, "Failed to create ECDSA key generation session", natMsg) + if err := types.ValidateKeyProtocol(types.KeyTypeSecp256k1, msg.ECDSAProtocol); err != nil { + ec.handleKeygenSessionError(msg.WalletID, err, "Invalid ECDSA protocol", natMsg) return } - eddsaSession, err := ec.node.CreateKeyGenSession(mpc.SessionTypeEDDSA, walletID, ec.mpcThreshold, ec.genKeyResultQueue) - if err != nil { - ec.handleKeygenSessionError(walletID, err, "Failed to create EdDSA key generation session", natMsg) + + if err := types.ValidateKeyProtocol(types.KeyTypeEd25519, msg.EdDSAProtocol); err != nil { + ec.handleKeygenSessionError(msg.WalletID, err, "Invalid EdDSA protocol", natMsg) return } - ecdsaSession.Init() - eddsaSession.Init() - ctxEcdsa, doneEcdsa := context.WithCancel(baseCtx) - ctxEddsa, doneEddsa := context.WithCancel(baseCtx) + walletID := msg.WalletID + logger.Info( + "[KEYGEN START]", + "walletID", + walletID, + "ecdsa_protocol", + msg.ECDSAProtocol, + "eddsa_protocol", + msg.EdDSAProtocol, + ) + + ctx, cancelAll := context.WithCancel(baseCtx) + defer cancelAll() - successEvent := &event.KeygenResultEvent{WalletID: walletID, ResultType: event.ResultTypeSuccess} + successEvent := &event.KeygenResultEvent{ + WalletID: walletID, + ResultType: event.ResultTypeSuccess, + ECDSAPubKey: nil, + EDDSAPubKey: nil, + } + + errCh := make(chan error, 2) var wg sync.WaitGroup - wg.Add(2) - // Channel to communicate errors from goroutines to main function - errorChan := make(chan error, 2) + wg.Add(2) + // run ECDSA keygen go func() { defer wg.Done() - select { - case <-ctxEcdsa.Done(): - successEvent.ECDSAPubKey = ecdsaSession.GetPubKeyResult() - case err := <-ecdsaSession.ErrChan(): - logger.Error("ECDSA keygen session error", err) - ec.handleKeygenSessionError(walletID, err, "ECDSA keygen session error", natMsg) - errorChan <- err - doneEcdsa() + pub, err := ec.runECDSAKeygen(ctx, walletID, msg.ECDSAProtocol, natMsg) + if err != nil { + errCh <- err + cancelAll() + return } + successEvent.ECDSAPubKey = pub }() + + // run EdDSA keygen go func() { defer wg.Done() - select { - case <-ctxEddsa.Done(): - successEvent.EDDSAPubKey = eddsaSession.GetPubKeyResult() - case err := <-eddsaSession.ErrChan(): - logger.Error("EdDSA keygen session error", err) - ec.handleKeygenSessionError(walletID, err, "EdDSA keygen session error", natMsg) - errorChan <- err - doneEddsa() + pub, err := ec.runEdDSAKeygen(ctx, walletID, msg.EdDSAProtocol, natMsg) + if err != nil { + errCh <- err + cancelAll() + return } + successEvent.EDDSAPubKey = pub }() - ecdsaSession.ListenToIncomingMessageAsync() - eddsaSession.ListenToIncomingMessageAsync() - - // Temporary delay for peer setup - ec.warmUpSession() - go ecdsaSession.GenerateKey(doneEcdsa) - go eddsaSession.GenerateKey(doneEddsa) - - // Wait for completion or timeout - doneAll := make(chan struct{}) + waitDone := make(chan struct{}) go func() { wg.Wait() - close(doneAll) + close(waitDone) }() select { - case <-doneAll: - // Check if any errors occurred during execution - select { - case <-errorChan: - // Error already handled by the goroutine, just return early - return - default: - // No errors, continue with success + case <-waitDone: + close(errCh) + for err := range errCh { + if err != nil { + return + } } + case err := <-errCh: + cancelAll() + if err != nil { + logger.Error("keygen failed", err, "walletID", walletID) + } + return case <-baseCtx.Done(): - // timeout occurred - logger.Warn("Key generation timed out", "walletID", walletID, "timeout", KeyGenTimeOut) - ec.handleKeygenSessionError(walletID, fmt.Errorf("keygen session timed out after %v", KeyGenTimeOut), "Key generation timed out", natMsg) + cancelAll() + ec.handleKeygenSessionError( + walletID, + fmt.Errorf("keygen timeout after %v", KeyGenTimeOut), + "Keygen timeout", + natMsg, + ) return } @@ -353,6 +381,18 @@ func (ec *eventConsumer) handleSigningEvent(natMsg *nats.Msg) { return } + if verr := types.ValidateKeyProtocol(msg.KeyType, msg.Protocol); verr != nil { + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + verr, + verr.Error(), + natMsg, + ) + return + } + logger.Info( "Received signing event", "waleltID", @@ -365,132 +405,36 @@ func (ec *eventConsumer) handleSigningEvent(natMsg *nats.Msg) { ec.node.ID(), ) + // Track activity to detect hot wallets + ec.trackAndMaybeNotifyHot(msg) + // Check for duplicate session and track if new if ec.checkDuplicateSession(msg.WalletID, msg.TxID) { - duplicateErr := fmt.Errorf("duplicate signing request detected for walletID=%s txID=%s", msg.WalletID, msg.TxID) - ec.handleSigningSessionError( - msg.WalletID, - msg.TxID, - msg.NetworkInternalCode, - duplicateErr, - "Duplicate session", - natMsg, - ) - return - } - - var session mpc.SigningSession - idempotentKey := composeSigningIdempotentKey(msg.TxID, natMsg) - var sessionErr error - switch msg.KeyType { - case types.KeyTypeSecp256k1: - session, sessionErr = ec.node.CreateSigningSession( - mpc.SessionTypeECDSA, - msg.WalletID, - msg.TxID, - msg.NetworkInternalCode, - ec.signingResultQueue, - idempotentKey, - ) - case types.KeyTypeEd25519: - session, sessionErr = ec.node.CreateSigningSession( - mpc.SessionTypeEDDSA, + duplicateErr := fmt.Errorf( + "duplicate signing request detected for walletID=%s txID=%s", msg.WalletID, msg.TxID, - msg.NetworkInternalCode, - ec.signingResultQueue, - idempotentKey, ) - default: - sessionErr = fmt.Errorf("unsupported key type: %v", msg.KeyType) - } - if sessionErr != nil { - if errors.Is(sessionErr, mpc.ErrNotEnoughParticipants) { - logger.Info( - "RETRY LATER: Not enough participants to sign", - "walletID", msg.WalletID, - "txID", msg.TxID, - "nodeID", ec.node.ID(), - ) - //Return for retry later - return - } - - if errors.Is(sessionErr, mpc.ErrNotInParticipantList) { - logger.Info("Node is not in participant list for this wallet, skipping signing", - "walletID", msg.WalletID, - "txID", msg.TxID, - "nodeID", ec.node.ID(), - ) - // Skip signing instead of treating as error - return - } - ec.handleSigningSessionError( msg.WalletID, msg.TxID, msg.NetworkInternalCode, - sessionErr, - "Failed to create signing session", + duplicateErr, + "Duplicate session", natMsg, ) return } - txBigInt := new(big.Int).SetBytes(msg.Tx) - err = session.Init(txBigInt) - if err != nil { - ec.handleSigningSessionError( - msg.WalletID, - msg.TxID, - msg.NetworkInternalCode, - err, - "Failed to init signing session", - natMsg, - ) + // Route Taurus signing by algorithm (matches keygen behavior) + if msg.Protocol == types.ProtocolCGGMP21 || msg.Protocol == types.ProtocolTaproot || + msg.Protocol == types.ProtocolFROST { + ec.handleTaurusSigning(msg.Protocol, msg, natMsg) return } - // Mark session as already processed - ec.addSession(msg.WalletID, msg.TxID) - - ctx, done := context.WithCancel(context.Background()) - go func() { - for { - select { - case <-ctx.Done(): - return - case err := <-session.ErrChan(): - if err != nil { - ec.handleSigningSessionError( - msg.WalletID, - msg.TxID, - msg.NetworkInternalCode, - err, - "Failed to sign tx", - natMsg, - ) - return - } - } - } - }() - - session.ListenToIncomingMessageAsync() - // TODO: use consul distributed lock here, only sign after all nodes has already completed listing to incoming message async - // The purpose of the sleep is to be ensuring that the node has properly set up its message listeners - // before it starts the signing process. If the signing process starts sending messages before other nodes - // have set up their listeners, those messages might be missed, potentially causing the signing process to fail. - // One solution: - // The messaging includes mechanisms for direct point-to-point communication (in point2point.go). - // The nodes could explicitly coordinate through request-response patterns before starting signing - ec.warmUpSession() - - onSuccess := func(data []byte) { - done() - ec.sendReplyToRemoveMsg(natMsg) - } - go session.Sign(onSuccess) + // Classic signing (ECDSA/EDDSA) + ec.runClassicSigning(msg, natMsg) } func (ec *eventConsumer) consumeTxSigningEvent() error { @@ -505,6 +449,7 @@ func (ec *eventConsumer) consumeTxSigningEvent() error { return nil } + func (ec *eventConsumer) handleSigningSessionError(walletID, txID, networkInternalCode string, err error, contextMsg string, natMsg *nats.Msg) { fullErrMsg := fmt.Sprintf("%s: %v", contextMsg, err) errorCode := event.GetErrorCodeFromError(err) @@ -586,7 +531,25 @@ func (ec *eventConsumer) consumeReshareEvent() error { if err := ec.identityStore.VerifyInitiatorMessage(&msg); err != nil { logger.Error("Failed to verify initiator message", err) - ec.handleReshareSessionError(msg.WalletID, msg.KeyType, msg.NewThreshold, err, "Failed to verify initiator message", natMsg) + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + err, + "Failed to verify initiator message", + natMsg, + ) + return + } + if verr := types.ValidateKeyProtocol(msg.KeyType, msg.Protocol); verr != nil { + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + verr, + verr.Error(), + natMsg, + ) return } @@ -596,144 +559,25 @@ func (ec *eventConsumer) consumeReshareEvent() error { sessionType, err := sessionTypeFromKeyType(keyType) if err != nil { logger.Error("Failed to get session type", err) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to get session type", natMsg) - return - } - - createSession := func(isNewPeer bool) (mpc.ReshareSession, error) { - return ec.node.CreateReshareSession( - sessionType, + ec.handleReshareSessionError( walletID, + keyType, msg.NewThreshold, - msg.NodeIDs, - isNewPeer, - ec.reshareResultQueue, + err, + "Failed to get session type", + natMsg, ) - } - - oldSession, err := createSession(false) - if err != nil { - logger.Error("Failed to create old reshare session", err, "walletID", walletID) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to create old reshare session", natMsg) return } - newSession, err := createSession(true) - if err != nil { - logger.Error("Failed to create new reshare session", err, "walletID", walletID) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to create new reshare session", natMsg) + // Handle CMP reshare separately by algorithm + if msg.Protocol == types.ProtocolCGGMP21 || msg.Protocol == types.ProtocolTaproot || + msg.Protocol == types.ProtocolFROST { + ec.handleTaurusReshare(msg, natMsg) return } - if oldSession == nil && newSession == nil { - logger.Info("Node is not participating in this reshare (neither old nor new)", "walletID", walletID) - return - } - - ctx := context.Background() - var wg sync.WaitGroup - - successEvent := &event.ResharingResultEvent{ - WalletID: walletID, - NewThreshold: msg.NewThreshold, - KeyType: msg.KeyType, - ResultType: event.ResultTypeSuccess, - } - - if oldSession != nil { - err := oldSession.Init() - if err != nil { - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to init old reshare session", natMsg) - return - } - oldSession.ListenToIncomingMessageAsync() - } - - if newSession != nil { - err := newSession.Init() - if err != nil { - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to init new reshare session", natMsg) - return - } - newSession.ListenToIncomingMessageAsync() - // In resharing process, we need to ensure that the new session is aware of the old committee peers. - // Then new committee peers can start listening to the old committee peers - // and thus enable receiving direct messages from them. - extraOldCommiteePeers := newSession.GetLegacyCommitteePeers() - newSession.ListenToPeersAsync(extraOldCommiteePeers) - } - - ec.warmUpSession() - if oldSession != nil { - ctxOld, doneOld := context.WithCancel(ctx) - go oldSession.Reshare(doneOld) - - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-ctxOld.Done(): - return - case err := <-oldSession.ErrChan(): - logger.Error("Old reshare session error", err) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Old reshare session error", natMsg) - doneOld() - return - } - } - }() - } - - if newSession != nil { - ctxNew, doneNew := context.WithCancel(ctx) - go newSession.Reshare(doneNew) - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-ctxNew.Done(): - successEvent.PubKey = newSession.GetPubKeyResult() - return - case err := <-newSession.ErrChan(): - logger.Error("New reshare session error", err) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "New reshare session error", natMsg) - doneNew() - return - } - } - }() - } - - wg.Wait() - logger.Info("Reshare session finished", "walletID", walletID, "pubKey", fmt.Sprintf("%x", successEvent.PubKey)) - - if newSession != nil { - successBytes, err := json.Marshal(successEvent) - if err != nil { - logger.Error("Failed to marshal reshare success event", err) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to marshal reshare success event", natMsg) - return - } - - key := fmt.Sprintf(mpc.TypeReshareWalletResultFmt, msg.SessionID) - err = ec.reshareResultQueue.Enqueue( - key, - successBytes, - &messaging.EnqueueOptions{ - IdempotententKey: composeReshareIdempotentKey(msg.SessionID, natMsg), - }) - if err != nil { - logger.Error("Failed to publish reshare success message", err) - ec.handleReshareSessionError(walletID, keyType, msg.NewThreshold, err, "Failed to publish reshare success message", natMsg) - return - } - logger.Info("[COMPLETED RESHARE] Successfully published", "walletID", walletID) - } else { - logger.Info("[COMPLETED RESHARE] Done (not a new party)", "walletID", walletID) - } + ec.runClassicReshare(msg, natMsg, sessionType) }) - ec.reshareSub = sub return err } @@ -788,6 +632,139 @@ func (ec *eventConsumer) handleReshareSessionError( } } +func (ec *eventConsumer) consumePresignEvent() error { + sub, err := ec.pubsub.Subscribe(MPCPresignEvent, func(natMsg *nats.Msg) { + var msg types.PresignTxMessage + if err := json.Unmarshal(natMsg.Data, &msg); err != nil { + logger.Error("Failed to unmarshal presign message", err) + return + } + if err := ec.identityStore.VerifyInitiatorMessage(&msg); err != nil { + logger.Error("Failed to verify initiator message", err) + return + } + + // Only CGGMP21 supports presign + if msg.Protocol != types.ProtocolCGGMP21 { + ec.handlePresignSessionError(msg.WalletID, + fmt.Errorf("presign is only supported for CGGMP21 key type"), + "Unsupported key type for presign", + natMsg, + ) + return + } + session, err := ec.node.CreateTaurusSession(msg.WalletID, ec.mpcThreshold, msg.Protocol, taurus.ActPresign) + if err != nil { + ec.handlePresignSessionError(msg.WalletID, + err, "Failed to create presign session", + natMsg, + ) + return + } + + ctx := context.Background() + success, err := session.Presign(ctx, msg.TxID) + if err != nil { + ec.handlePresignSessionError(msg.WalletID, + err, "Presign operation failed", + natMsg, + ) + return + } + + if success { + ec.handlePresignSessionSuccess(msg.WalletID, msg.TxID, natMsg) + } else { + ec.handlePresignSessionError(msg.WalletID, + fmt.Errorf("presign operation returned false"), + "Presign operation failed", + natMsg, + ) + } + }) + if err != nil { + return err + } + + ec.presignSub = sub + return nil +} + +// handlePresignSessionSuccess handles successful presign operations +func (ec *eventConsumer) handlePresignSessionSuccess(walletID string, txID string, natMsg *nats.Msg) { + presignResult := event.PresignResultEvent{ + ResultType: event.ResultTypeSuccess, + WalletID: walletID, + TxID: txID, + Status: "success", + } + + presignResultBytes, err := json.Marshal(presignResult) + if err != nil { + logger.Error("Failed to marshal presign result event", err, + "walletID", walletID, + ) + return + } + + err = ec.presignResultQueue.Enqueue(event.PresignResultTopic, presignResultBytes, &messaging.EnqueueOptions{ + IdempotententKey: composePresignIdempotentKey(txID, natMsg), + }) + if err != nil { + logger.Error("Failed to enqueue presign result event", err, + "walletID", walletID, + "payload", string(presignResultBytes), + ) + } + // Presign events don't use reply inboxes, so no need to send reply + logger.Info("[COMPLETED PRESIGN] Presign completed successfully", "walletID", walletID, "txID", txID) +} + +// handlePresignSessionError handles errors that occur during presign operations +func (ec *eventConsumer) handlePresignSessionError(walletID string, err error, contextMsg string, natMsg *nats.Msg) { + fullErrMsg := fmt.Sprintf("%s: %v", contextMsg, err) + errorCode := event.GetErrorCodeFromError(err) + + logger.Warn("Presign session error", + "walletID", walletID, + "error", err.Error(), + "errorCode", errorCode, + "context", contextMsg, + ) + + presignResult := event.PresignResultEvent{ + ResultType: event.ResultTypeError, + ErrorCode: errorCode, + WalletID: walletID, + ErrorReason: fullErrMsg, + Status: "failed", + } + + presignResultBytes, err := json.Marshal(presignResult) + if err != nil { + logger.Error("Failed to marshal presign result event", err, + "walletID", walletID, + ) + return + } + + err = ec.presignResultQueue.Enqueue(event.PresignResultTopic, presignResultBytes, &messaging.EnqueueOptions{ + IdempotententKey: composePresignIdempotentKey(walletID, natMsg), + }) + if err != nil { + logger.Error("Failed to enqueue presign result event", err, + "walletID", walletID, + "payload", string(presignResultBytes), + ) + } + // Presign events don't use reply inboxes, so no need to send reply +} + +// composePresignIdempotentKey creates an idempotent key for presign operations +func composePresignIdempotentKey(walletID string, natMsg *nats.Msg) string { + return fmt.Sprintf("presign:%s:%s", walletID, natMsg.Header.Get("Nats-Msg-Id")) +} + // Add a cleanup routine that runs periodically func (ec *eventConsumer) sessionCleanupRoutine() { ticker := time.NewTicker(ec.cleanupInterval) @@ -902,3 +879,38 @@ func composeSigningIdempotentKey(txID string, natMsg *nats.Msg) string { func composeReshareIdempotentKey(sessionID string, natMsg *nats.Msg) string { return composeIdempotentKey(sessionID, natMsg, mpc.TypeReshareWalletResultFmt) } + +// trackAndMaybeNotifyHot records a signing event and publishes a hot wallet event +// if at least hotThreshold signs occur within hotWindow for the same +// (walletID, keyType, protocol) tuple. +func (ec *eventConsumer) trackAndMaybeNotifyHot(msg types.SignTxMessage) { + if msg.Protocol != types.ProtocolCGGMP21 { + return + } + key := fmt.Sprintf("%s:%s:%s", msg.WalletID, string(msg.KeyType), string(msg.Protocol)) + now := time.Now() + + ec.hotMu.Lock() + // prune old entries + list := ec.recentSigns[key] + pruned := list[:0] + cutoff := now.Add(-ec.hotWindow) + for _, t := range list { + if t.After(cutoff) { + pruned = append(pruned, t) + } + } + + ec.recentSigns[key] = append([]time.Time(nil), pruned...) + currentCount := len(ec.recentSigns[key]) + + // If this push reaches the threshold, publish hot wallet once + shouldPublish := currentCount+1 == ec.hotThreshold + ec.recentSigns[key] = append(ec.recentSigns[key], now) + ec.hotMu.Unlock() + + if shouldPublish { + _ = ec.pubsub.Publish(MPCHotWalletEvent, []byte(msg.WalletID)) + logger.Info("Published hot wallet event", "walletID", msg.WalletID) + } +} diff --git a/pkg/eventconsumer/keygen_runner.go b/pkg/eventconsumer/keygen_runner.go new file mode 100644 index 0000000..3d52659 --- /dev/null +++ b/pkg/eventconsumer/keygen_runner.go @@ -0,0 +1,153 @@ +package eventconsumer + +import ( + "context" + "fmt" + + "github.com/fystack/mpcium/pkg/mpc" + "github.com/fystack/mpcium/pkg/mpc/taurus" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +func (ec *eventConsumer) runECDSAKeygen( + ctx context.Context, + walletID string, + algo types.Protocol, + natMsg *nats.Msg, +) ([]byte, error) { + switch algo { + case types.ProtocolCGGMP21: + ts, err := ec.node.CreateTaurusSession( + walletID, + ec.mpcThreshold, + types.ProtocolCGGMP21, + taurus.ActKeygen, + ) + if err != nil { + return nil, err + } + res, err := ts.Keygen(ctx) + if err != nil { + return nil, err + } + return res.PubKeyBytes, nil + + case types.ProtocolFROST: + ts, err := ec.node.CreateTaurusSession( + walletID, + ec.mpcThreshold, + types.ProtocolFROST, + taurus.ActKeygen, + ) + if err != nil { + return nil, err + } + res, err := ts.Keygen(ctx) + if err != nil { + return nil, err + } + return res.PubKeyBytes, nil + + case types.ProtocolTaproot: + ts, err := ec.node.CreateTaurusSession( + walletID, + ec.mpcThreshold, + types.ProtocolTaproot, + taurus.ActKeygen, + ) + if err != nil { + return nil, err + } + res, err := ts.Keygen(ctx) + if err != nil { + return nil, err + } + return res.PubKeyBytes, nil + case types.ProtocolGG18: + fallthrough + default: + // Fallback to GG18 ECDSA when algorithm is GG18 or unspecified/unknown + sess, err := ec.node.CreateKeyGenSession( + mpc.SessionTypeECDSA, + walletID, + ec.mpcThreshold, + ec.genKeyResultQueue, + ) + if err != nil { + ec.handleKeygenSessionError( + walletID, + err, + "Failed to create ECDSA (GG18) session", + natMsg, + ) + return nil, err + } + sess.Init() + sess.ListenToIncomingMessageAsync() + ec.warmUpSession() + + ctxLocal, cancel := context.WithCancel(ctx) + defer cancel() + go sess.GenerateKey(cancel) + + select { + case err := <-sess.ErrChan(): + if err != nil { + return nil, err + } + case <-ctxLocal.Done(): + // success + case <-ctx.Done(): + return nil, fmt.Errorf("ECDSA keygen cancelled") + } + return sess.GetPubKeyResult(), nil + } +} + +func (ec *eventConsumer) runEdDSAKeygen( + ctx context.Context, + walletID string, + algo types.Protocol, + natMsg *nats.Msg, +) ([]byte, error) { + switch algo { + case types.ProtocolGG18: + fallthrough + default: + sess, err := ec.node.CreateKeyGenSession( + mpc.SessionTypeEDDSA, + walletID, + ec.mpcThreshold, + ec.genKeyResultQueue, + ) + if err != nil { + ec.handleKeygenSessionError( + walletID, + err, + "Failed to create EdDSA keygen session", + natMsg, + ) + return nil, err + } + sess.Init() + sess.ListenToIncomingMessageAsync() + ec.warmUpSession() + + ctxLocal, cancel := context.WithCancel(ctx) + defer cancel() + go sess.GenerateKey(cancel) + + select { + case err := <-sess.ErrChan(): + if err != nil { + return nil, err + } + case <-ctxLocal.Done(): + // success + case <-ctx.Done(): + return nil, fmt.Errorf("EdDSA keygen cancelled") + } + return sess.GetPubKeyResult(), nil + } +} diff --git a/pkg/eventconsumer/reshare_runner.go b/pkg/eventconsumer/reshare_runner.go new file mode 100644 index 0000000..348db57 --- /dev/null +++ b/pkg/eventconsumer/reshare_runner.go @@ -0,0 +1,390 @@ +package eventconsumer + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/mpc" + "github.com/fystack/mpcium/pkg/mpc/taurus" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +// NOTE: In Taurus reshare, it just refresh the keyshare of each node but keep the same public key and threshold. +// Therefore, we don't need to create new party sessions for CMP reshare. +func (ec *eventConsumer) handleTaurusReshare(msg types.ResharingMessage, natMsg *nats.Msg) { + logger.Info( + "Starting reshare", + "walletID", + msg.WalletID, + "sessionID", + msg.SessionID, + "keyType", + msg.KeyType, + ) + + // Create Taurus session for reshare + session, err := ec.node.CreateTaurusSession( + msg.WalletID, + msg.NewThreshold, + msg.Protocol, + taurus.ActReshare, + ) + if err != nil { + logger.Error( + "Failed to create reshare session", + err, + "walletID", + msg.WalletID, + "keyType", + msg.KeyType, + ) + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + err, + fmt.Sprintf("Failed to create %s reshare session", msg.KeyType), + natMsg, + ) + return + } + + // Load the existing key for reshare + if err := session.LoadKey(msg.WalletID); err != nil { + logger.Error( + "Failed to load key for reshare", + err, + "walletID", + msg.WalletID, + "keyType", + msg.KeyType, + ) + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + err, + fmt.Sprintf("Failed to load key for %s reshare", msg.KeyType), + natMsg, + ) + return + } + + // Create context for reshare + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Second, + ) // Longer timeout for reshare + defer cancel() + + // Perform reshare + keyData, err := session.Reshare(ctx) + if err != nil { + logger.Error( + "Reshare failed", + err, + "walletID", + msg.WalletID, + "sessionID", + msg.SessionID, + "keyType", + msg.KeyType, + ) + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + err, + fmt.Sprintf("Reshare failed for %s", msg.KeyType), + natMsg, + ) + return + } + + // Create reshare result event + reshareResult := event.ResharingResultEvent{ + ResultType: event.ResultTypeSuccess, + WalletID: msg.WalletID, + NewThreshold: keyData.Threshold, + KeyType: msg.KeyType, + PubKey: keyData.PubKeyBytes, + } + + // Marshal and enqueue the result + reshareResultBytes, err := json.Marshal(reshareResult) + if err != nil { + logger.Error( + "Failed to marshal reshare result event", + err, + "walletID", + msg.WalletID, + "sessionID", + msg.SessionID, + "keyType", + msg.KeyType, + ) + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + err, + fmt.Sprintf("Failed to marshal %s reshare result", msg.KeyType), + natMsg, + ) + return + } + + // Enqueue the reshare result + key := fmt.Sprintf(mpc.TypeReshareWalletResultFmt, msg.SessionID) + err = ec.reshareResultQueue.Enqueue(key, reshareResultBytes, &messaging.EnqueueOptions{ + IdempotententKey: composeReshareIdempotentKey(msg.SessionID, natMsg), + }) + if err != nil { + logger.Error( + "Failed to enqueue reshare result event", + err, + "walletID", + msg.WalletID, + "sessionID", + msg.SessionID, + "keyType", + msg.KeyType, + ) + ec.handleReshareSessionError( + msg.WalletID, + msg.KeyType, + msg.NewThreshold, + err, + fmt.Sprintf("Failed to enqueue %s reshare result", msg.KeyType), + natMsg, + ) + return + } + + // Remove this line - don't send reply for reshare messages + // ec.sendReplyToRemoveMsg(natMsg) + + logger.Info( + "[COMPLETED RESHARE] CMP reshare completed successfully", + "walletID", + msg.WalletID, + "sessionID", + msg.SessionID, + ) +} + +// runClassicReshare handles non-Taurus reshare flows (ECDSA/EDDSA) +func (ec *eventConsumer) runClassicReshare( + msg types.ResharingMessage, + natMsg *nats.Msg, + sessionType mpc.SessionType, +) { + walletID := msg.WalletID + keyType := msg.KeyType + + createSession := func(isNewPeer bool) (mpc.ReshareSession, error) { + return ec.node.CreateReshareSession( + sessionType, + walletID, + msg.NewThreshold, + msg.NodeIDs, + isNewPeer, + ec.reshareResultQueue, + ) + } + + oldSession, err := createSession(false) + if err != nil { + logger.Error("Failed to create old reshare session", err, "walletID", walletID) + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Failed to create old reshare session", + natMsg, + ) + return + } + newSession, err := createSession(true) + if err != nil { + logger.Error("Failed to create new reshare session", err, "walletID", walletID) + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Failed to create new reshare session", + natMsg, + ) + return + } + + if oldSession == nil && newSession == nil { + logger.Info( + "Node is not participating in this reshare (neither old nor new)", + "walletID", + walletID, + ) + return + } + + ctx := context.Background() + var wg sync.WaitGroup + + successEvent := &event.ResharingResultEvent{ + WalletID: walletID, + NewThreshold: msg.NewThreshold, + KeyType: msg.KeyType, + ResultType: event.ResultTypeSuccess, + } + + if oldSession != nil { + err := oldSession.Init() + if err != nil { + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Failed to init old reshare session", + natMsg, + ) + return + } + oldSession.ListenToIncomingMessageAsync() + } + + if newSession != nil { + err := newSession.Init() + if err != nil { + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Failed to init new reshare session", + natMsg, + ) + return + } + newSession.ListenToIncomingMessageAsync() + // In resharing process, we need to ensure that the new session is aware of the old committee peers. + // Then new committee peers can start listening to the old committee peers + // and thus enable receiving direct messages from them. + extraOldCommiteePeers := newSession.GetLegacyCommitteePeers() + newSession.ListenToPeersAsync(extraOldCommiteePeers) + } + + ec.warmUpSession() + if oldSession != nil { + ctxOld, doneOld := context.WithCancel(ctx) + go oldSession.Reshare(doneOld) + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctxOld.Done(): + return + case err := <-oldSession.ErrChan(): + logger.Error("Old reshare session error", err) + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Old reshare session error", + natMsg, + ) + doneOld() + return + } + } + }() + } + + if newSession != nil { + ctxNew, doneNew := context.WithCancel(ctx) + go newSession.Reshare(doneNew) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctxNew.Done(): + successEvent.PubKey = newSession.GetPubKeyResult() + return + case err := <-newSession.ErrChan(): + logger.Error("New reshare session error", err) + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "New reshare session error", + natMsg, + ) + doneNew() + return + } + } + }() + } + + wg.Wait() + logger.Info( + "Reshare session finished", + "walletID", + walletID, + "pubKey", + fmt.Sprintf("%x", successEvent.PubKey), + ) + + if newSession != nil { + successBytes, err := json.Marshal(successEvent) + if err != nil { + logger.Error("Failed to marshal reshare success event", err) + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Failed to marshal reshare success event", + natMsg, + ) + return + } + + key := fmt.Sprintf(mpc.TypeReshareWalletResultFmt, msg.SessionID) + err = ec.reshareResultQueue.Enqueue( + key, + successBytes, + &messaging.EnqueueOptions{ + IdempotententKey: composeReshareIdempotentKey(msg.SessionID, natMsg), + }) + if err != nil { + logger.Error("Failed to publish reshare success message", err) + ec.handleReshareSessionError( + walletID, + keyType, + msg.NewThreshold, + err, + "Failed to publish reshare success message", + natMsg, + ) + return + } + logger.Info("[COMPLETED RESHARE] Successfully published", "walletID", walletID) + } else { + logger.Info("[COMPLETED RESHARE] Done (not a new party)", "walletID", walletID) + } +} diff --git a/pkg/eventconsumer/sign_runner.go b/pkg/eventconsumer/sign_runner.go new file mode 100644 index 0000000..8ac1c7a --- /dev/null +++ b/pkg/eventconsumer/sign_runner.go @@ -0,0 +1,274 @@ +package eventconsumer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/mpc" + "github.com/fystack/mpcium/pkg/mpc/taurus" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" +) + +func (ec *eventConsumer) handleTaurusSigning( + algorithm types.Protocol, + msg types.SignTxMessage, + natMsg *nats.Msg, +) { + logger.Info( + "Starting signing", + "walletID", + msg.WalletID, + "txID", + msg.TxID, + "algorithm", + algorithm, + ) + session, err := ec.node.CreateTaurusSession( + msg.WalletID, + ec.mpcThreshold, + algorithm, + taurus.ActSign, + ) + if err != nil { + logger.Error("Failed to create session", err, "walletID", msg.WalletID) + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + err, + fmt.Sprintf("Failed to create %s session: %v", algorithm, err), + natMsg, + ) + return + } + + // Convert transaction bytes to big.Int + txBigInt := new(big.Int).SetBytes(msg.Tx) + + // Create context for signing + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + signature, err := session.Sign(ctx, txBigInt) + if err != nil { + logger.Error( + "signing failed", + err, + "algorithm", + algorithm, + "walletID", + msg.WalletID, + "txID", + msg.TxID, + ) + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + err, + fmt.Sprintf("%s signing failed", algorithm), + natMsg, + ) + return + } + + // Create signing result event + signingResult := event.SigningResultEvent{ + ResultType: event.ResultTypeSuccess, + NetworkInternalCode: msg.NetworkInternalCode, + WalletID: msg.WalletID, + TxID: msg.TxID, + Signature: signature, // Returns the full signature + } + + // Marshal and enqueue the result + signingResultBytes, err := json.Marshal(signingResult) + if err != nil { + logger.Error( + "Failed to marshal signing result event", + err, + "algorithm", + algorithm, + "walletID", + msg.WalletID, + "txID", + msg.TxID, + ) + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + err, + fmt.Sprintf("Failed to marshal %s signing result", algorithm), + natMsg, + ) + return + } + + // Enqueue the signing result + err = ec.signingResultQueue.Enqueue( + event.SigningResultCompleteTopic, + signingResultBytes, + &messaging.EnqueueOptions{ + IdempotententKey: composeSigningIdempotentKey(msg.TxID, natMsg), + }, + ) + if err != nil { + logger.Error( + "Failed to enqueue signing result event", + err, + "algorithm", + algorithm, + "walletID", + msg.WalletID, + "txID", + msg.TxID, + ) + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + err, + fmt.Sprintf("Failed to enqueue %s signing result", algorithm), + natMsg, + ) + return + } + + // Send reply and log success + ec.sendReplyToRemoveMsg(natMsg) + logger.Info( + "[COMPLETED SIGN] signing completed successfully", + "algorithm", + algorithm, + "walletID", + msg.WalletID, + "txID", + msg.TxID, + ) +} + +// runClassicSigning handles non-Taurus signing flows (ECDSA/EDDSA) +func (ec *eventConsumer) runClassicSigning(msg types.SignTxMessage, natMsg *nats.Msg) { + var session mpc.SigningSession + idempotentKey := composeSigningIdempotentKey(msg.TxID, natMsg) + + var sessionErr error + switch msg.KeyType { + case types.KeyTypeSecp256k1: + session, sessionErr = ec.node.CreateSigningSession( + mpc.SessionTypeECDSA, + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + ec.signingResultQueue, + idempotentKey, + ) + case types.KeyTypeEd25519: + session, sessionErr = ec.node.CreateSigningSession( + mpc.SessionTypeEDDSA, + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + ec.signingResultQueue, + idempotentKey, + ) + default: + sessionErr = fmt.Errorf("unsupported key type: %v", msg.KeyType) + } + if sessionErr != nil { + if errors.Is(sessionErr, mpc.ErrNotEnoughParticipants) { + logger.Info( + "RETRY LATER: Not enough participants to sign", + "walletID", msg.WalletID, + "txID", msg.TxID, + "nodeID", ec.node.ID(), + ) + //Return for retry later + return + } + + if errors.Is(sessionErr, mpc.ErrNotInParticipantList) { + logger.Info("Node is not in participant list for this wallet, skipping signing", + "walletID", msg.WalletID, + "txID", msg.TxID, + "nodeID", ec.node.ID(), + ) + // Skip signing instead of treating as error + return + } + + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + sessionErr, + "Failed to create signing session", + natMsg, + ) + return + } + + txBigInt := new(big.Int).SetBytes(msg.Tx) + err := session.Init(txBigInt) + if err != nil { + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + err, + "Failed to init signing session", + natMsg, + ) + return + } + + // Mark session as already processed + ec.addSession(msg.WalletID, msg.TxID) + + ctx, done := context.WithCancel(context.Background()) + go func() { + for { + select { + case <-ctx.Done(): + return + case err := <-session.ErrChan(): + if err != nil { + ec.handleSigningSessionError( + msg.WalletID, + msg.TxID, + msg.NetworkInternalCode, + err, + "Failed to sign tx", + natMsg, + ) + return + } + } + } + }() + + session.ListenToIncomingMessageAsync() + // TODO: use consul distributed lock here, only sign after all nodes has already completed listing to incoming message async + // The purpose of the sleep is to be ensuring that the node has properly set up its message listeners + // before it starts the signing process. If the signing process starts sending messages before other nodes + // have set up their listeners, those messages might be missed, potentially causing the signing process to fail. + // One solution: + // The messaging includes mechanisms for direct point-to-point communication (in point2point.go). + // The nodes could explicitly coordinate through request-response patterns before starting signing + ec.warmUpSession() + + onSuccess := func(data []byte) { + done() + ec.sendReplyToRemoveMsg(natMsg) + } + go session.Sign(onSuccess) +} diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 2d2f1ee..5509932 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -42,6 +42,9 @@ type Store interface { SignMessage(msg *types.TssMessage) ([]byte, error) VerifyMessage(msg *types.TssMessage) error + SignTaurusMessage(msg *types.TaurusMessage) ([]byte, error) + VerifyTaurusMessage(msg *types.TaurusMessage) error + SignEcdhMessage(msg *types.ECDHMessage) ([]byte, error) VerifySignature(msg *types.ECDHMessage) error @@ -101,7 +104,6 @@ func NewFileStore(identityDir, nodeName string, decrypt bool, agePasswordFile st if err != nil { return nil, fmt.Errorf("failed to read peers.json: %w", err) } - peers := make(map[string]string) if err := json.Unmarshal(peersData, &peers); err != nil { return nil, fmt.Errorf("failed to parse peers.json: %w", err) @@ -427,6 +429,45 @@ func (s *fileStore) VerifyMessage(msg *types.TssMessage) error { return nil } +func (s *fileStore) SignTaurusMessage(msg *types.TaurusMessage) ([]byte, error) { + // Get deterministic bytes for signing + msgBytes, err := msg.MarshalForSigning() + if err != nil { + return nil, fmt.Errorf("failed to marshal message for signing: %w", err) + } + + signature := ed25519.Sign(s.privateKey, msgBytes) + return signature, nil +} + +func (s *fileStore) VerifyTaurusMessage(msg *types.TaurusMessage) error { + if msg.Signature == nil { + return fmt.Errorf("message has no signature") + } + + // Get the sender's NodeID + senderNodeID := taurusPartyIDToNodeID(msg.From) + + // Get the sender's public key + publicKey, err := s.GetPublicKey(senderNodeID) + if err != nil { + return fmt.Errorf("failed to get sender's public key: %w", err) + } + + // Get deterministic bytes for verification + msgBytes, err := msg.MarshalForSigning() + if err != nil { + return fmt.Errorf("failed to marshal message for verification: %w", err) + } + + // Verify the signature + if !ed25519.Verify(publicKey, msgBytes, msg.Signature) { + return fmt.Errorf("invalid signature") + } + + return nil +} + func (s *fileStore) EncryptMessage(plaintext []byte, peerID string) ([]byte, error) { key, err := s.GetSymmetricKey(peerID) if err != nil { @@ -536,3 +577,7 @@ func (s *fileStore) verifyP256(msg types.InitiatorMessage) error { func partyIDToNodeID(partyID *tss.PartyID) string { return strings.Split(string(partyID.KeyInt().Bytes()), ":")[0] } + +func taurusPartyIDToNodeID(partyID string) string { + return strings.Split(partyID, ":")[0] +} diff --git a/pkg/mpc/node.go b/pkg/mpc/node.go index d615444..4cb2b49 100644 --- a/pkg/mpc/node.go +++ b/pkg/mpc/node.go @@ -14,6 +14,10 @@ import ( "github.com/fystack/mpcium/pkg/kvstore" "github.com/fystack/mpcium/pkg/logger" "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/mpc/taurus" + "github.com/fystack/mpcium/pkg/presigninfo" + "github.com/fystack/mpcium/pkg/types" + "github.com/taurusgroup/multi-party-sig/pkg/party" ) const ( @@ -31,12 +35,13 @@ type Node struct { nodeID string peerIDs []string - pubSub messaging.PubSub - direct messaging.DirectMessaging - kvstore kvstore.KVStore - keyinfoStore keyinfo.Store - ecdsaPreParams []*keygen.LocalPreParams - identityStore identity.Store + pubSub messaging.PubSub + direct messaging.DirectMessaging + kvstore kvstore.KVStore + keyinfoStore keyinfo.Store + presignInfoStore presigninfo.Store + ecdsaPreParams []*keygen.LocalPreParams + identityStore identity.Store peerRegistry PeerRegistry } @@ -48,6 +53,7 @@ func NewNode( direct messaging.DirectMessaging, kvstore kvstore.KVStore, keyinfoStore keyinfo.Store, + presignInfoStore presigninfo.Store, peerRegistry PeerRegistry, identityStore identity.Store, ) *Node { @@ -56,14 +62,15 @@ func NewNode( logger.Info("Starting new node, preparams is generated successfully!", "elapsed", elapsed.Milliseconds()) node := &Node{ - nodeID: nodeID, - peerIDs: peerIDs, - pubSub: pubSub, - direct: direct, - kvstore: kvstore, - keyinfoStore: keyinfoStore, - peerRegistry: peerRegistry, - identityStore: identityStore, + nodeID: nodeID, + peerIDs: peerIDs, + pubSub: pubSub, + direct: direct, + kvstore: kvstore, + keyinfoStore: keyinfoStore, + presignInfoStore: presignInfoStore, + peerRegistry: peerRegistry, + identityStore: identityStore, } node.ecdsaPreParams = node.generatePreParams() @@ -140,6 +147,36 @@ func (p *Node) createEDDSAKeyGenSession(walletID string, threshold int, version return session, nil } +func (p *Node) CreateTaurusSession( + walletID string, + threshold int, + protocol types.Protocol, + act taurus.Act, +) (taurus.TaurusSession, error) { + readyPeerIDs := p.peerRegistry.GetReadyPeersIncludeSelf() + selfPartyID, allPartyIDs := p.generateTaurusPartyIDs(PurposeKeygen, readyPeerIDs, DefaultVersion) + var session taurus.TaurusSession + switch protocol { + case types.ProtocolCGGMP21: + tr := taurus.NewNATSTransport(walletID, selfPartyID, act, taurus.CGGMP21, p.pubSub, p.direct, p.identityStore) + session = taurus.NewCGGMP21Session(walletID, selfPartyID, allPartyIDs, threshold, p.presignInfoStore, tr, p.kvstore, p.keyinfoStore) + case types.ProtocolTaproot: + tr := taurus.NewNATSTransport(walletID, selfPartyID, act, taurus.FROSTTaproot, p.pubSub, p.direct, p.identityStore) + session = taurus.NewTaprootSession(walletID, selfPartyID, allPartyIDs, threshold, tr, p.kvstore, p.keyinfoStore) + case types.ProtocolFROST: + tr := taurus.NewNATSTransport(walletID, selfPartyID, act, taurus.FROST, p.pubSub, p.direct, p.identityStore) + session = taurus.NewFROSTSession(walletID, selfPartyID, allPartyIDs, threshold, tr, p.kvstore, p.keyinfoStore) + } + + if act == taurus.ActSign || act == taurus.ActReshare || act == taurus.ActPresign { + err := session.LoadKey(walletID) + if err != nil { + return nil, err + } + } + return session, nil +} + func (p *Node) CreateSigningSession( sessionType SessionType, walletID string, @@ -471,3 +508,27 @@ func sessionKeyPrefix(sessionType SessionType) (string, error) { return "", fmt.Errorf("unsupported session type: %v", sessionType) } } + +func (p *Node) generateTaurusPartyIDs(purpose string, peerIDs []string, version int) (party.ID, party.IDSlice) { + partyIDs := make(party.IDSlice, len(peerIDs)) + var selfPartyID party.ID + + for i, peerID := range peerIDs { + partyID := createTaurusPartyID(peerID, purpose, version) + partyIDs[i] = partyID + if peerID == p.nodeID { + selfPartyID = partyID + } + } + + return selfPartyID, partyIDs +} + +func createTaurusPartyID(sessionID string, keyType string, version int) party.ID { + if version == 0 { + // Backward compatible version - just use sessionID + return party.ID(sessionID) + } + // Include version in party ID + return party.ID(fmt.Sprintf("%s:%s:%d", sessionID, keyType, version)) +} diff --git a/pkg/mpc/session.go b/pkg/mpc/session.go index b1a76b5..43e64ea 100644 --- a/pkg/mpc/session.go +++ b/pkg/mpc/session.go @@ -25,8 +25,11 @@ const ( TypeReshareWalletResultFmt = "mpc.mpc_reshare_result.%s" TypeSigningResultFmt = "mpc.mpc_signing_result.%s" - SessionTypeECDSA SessionType = "session_ecdsa" - SessionTypeEDDSA SessionType = "session_eddsa" + SessionTypeECDSA SessionType = "session_ecdsa" + SessionTypeEDDSA SessionType = "session_eddsa" + SessionTypeCGGMP21 SessionType = "session_cggmp21" + SessionTypeTaproot SessionType = "session_taproot" + SessionTypeFROST SessionType = "session_frost" ) var ( diff --git a/pkg/mpc/taurus/adapter.go b/pkg/mpc/taurus/adapter.go new file mode 100644 index 0000000..d46a398 --- /dev/null +++ b/pkg/mpc/taurus/adapter.go @@ -0,0 +1,71 @@ +package taurus + +import ( + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/taurusgroup/multi-party-sig/pkg/party" + "github.com/taurusgroup/multi-party-sig/pkg/protocol" +) + +type NetworkAdapter struct { + sid string + selfID party.ID + peers party.IDSlice + transport Transport + inbox chan *protocol.Message +} + +func NewNetworkAdapter( + sid string, + selfID party.ID, + t Transport, + peers party.IDSlice, +) *NetworkAdapter { + a := &NetworkAdapter{ + sid: sid, + selfID: selfID, + peers: peers, + transport: t, + inbox: make(chan *protocol.Message, 100), + } + go a.route() + return a +} + +func (a *NetworkAdapter) Next() <-chan *protocol.Message { return a.inbox } + +func (a *NetworkAdapter) Send(msg *protocol.Message) { + wire, err := msg.MarshalBinary() + if err != nil { + logger.Error("marshal protocol msg", err) + return + } + m := types.TaurusMessage{ + SID: a.sid, + From: string(msg.From), + To: []string{string(msg.To)}, + IsBroadcast: msg.Broadcast, + Data: wire, + } + for _, pid := range a.peers { + if pid != a.selfID && (msg.Broadcast || msg.IsFor(pid)) { + _ = a.transport.Send(string(pid), m) + } + } +} + +func (a *NetworkAdapter) route() { + for tm := range a.transport.Inbox() { + var pm protocol.Message + if err := pm.UnmarshalBinary(tm.Data); err != nil { + logger.Error("unmarshal protocol msg", err) + continue + } + + select { + case a.inbox <- &pm: + default: + logger.Warn("inbox full, drop msg", "self", a.selfID) + } + } +} diff --git a/pkg/mpc/taurus/cggmp21.go b/pkg/mpc/taurus/cggmp21.go new file mode 100644 index 0000000..40818f4 --- /dev/null +++ b/pkg/mpc/taurus/cggmp21.go @@ -0,0 +1,368 @@ +package taurus + +import ( + "context" + cryptoEcdsa "crypto/ecdsa" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "math/big" + "sort" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/fxamacker/cbor/v2" + "github.com/fystack/mpcium/pkg/encoding" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/presigninfo" + "github.com/fystack/mpcium/pkg/types" + "github.com/taurusgroup/multi-party-sig/pkg/ecdsa" + "github.com/taurusgroup/multi-party-sig/pkg/math/curve" + "github.com/taurusgroup/multi-party-sig/pkg/party" + "github.com/taurusgroup/multi-party-sig/pkg/pool" + "github.com/taurusgroup/multi-party-sig/protocols/cmp" +) + +type CGGMP21Session struct { + *commonSession + workerPool *pool.Pool + savedData *cmp.Config + presignInfoStore presigninfo.Store +} + +func NewCGGMP21Session( + sessionID string, + selfID party.ID, + peerIDs party.IDSlice, + threshold int, + presignInfoStore presigninfo.Store, + transport Transport, + kvstore kvstore.KVStore, + keyinfoStore keyinfo.Store, +) TaurusSession { + commonSession := NewCommonSession( + sessionID, + selfID, + peerIDs, + threshold, + transport, + kvstore, + keyinfoStore, + ) + return &CGGMP21Session{ + commonSession: commonSession, + workerPool: pool.NewPool(0), + savedData: nil, + presignInfoStore: presignInfoStore, + } +} + +func (p *CGGMP21Session) LoadKey(sid string) error { + key := p.composeKey(sid) + + data, err := p.kvstore.Get(key) + if err != nil { + return fmt.Errorf("load key: %w", err) + } + + cfg := cmp.EmptyConfig(curve.Secp256k1{}) + if err := cfg.UnmarshalBinary(data); err != nil { + return fmt.Errorf("unmarshal key config: %w", err) + } + + p.savedData = cfg + return nil +} + +func (p *CGGMP21Session) Keygen(ctx context.Context) (types.KeyData, error) { + logger.Info("Starting to generate key CGGMP21", "walletID", p.sessionID) + + result, err := p.run( + ctx, + cmp.Keygen(curve.Secp256k1{}, p.selfID, p.peerIDs, p.threshold, p.workerPool), + ) + if err != nil { + return types.KeyData{}, err + } + + cfg, ok := result.(*cmp.Config) + if !ok { + return types.KeyData{}, fmt.Errorf("unexpected result type %T", result) + } + p.savedData = cfg + + packed, err := cfg.MarshalBinary() + if err != nil { + return types.KeyData{}, fmt.Errorf("marshal config: %w", err) + } + + x, y, err := extractPublicKey(cfg.PublicPoint()) + if err != nil { + return types.KeyData{}, fmt.Errorf("extract pubkey: %w", err) + } + + // Use secp256k1 curve, not P256 + pubKey := &cryptoEcdsa.PublicKey{ + Curve: btcec.S256(), + X: x, + Y: y, + } + + pubKeyBytes, err := encoding.EncodeS256PubKey(pubKey) + if err != nil { + return types.KeyData{}, fmt.Errorf("encode pubkey: %w", err) + } + + key := p.composeKey(p.sessionID) + keyInfo := &keyinfo.KeyInfo{ + ParticipantPeerIDs: p.getParticipantPeerIDs(), + Threshold: p.threshold, + Version: 1, + } + + // Store both key and metadata if stores available + if p.kvstore != nil { + if err := p.kvstore.Put(key, packed); err != nil { + return types.KeyData{}, fmt.Errorf("store key: %w", err) + } + } + if p.keyinfoStore != nil { + if err := p.keyinfoStore.Save(key, keyInfo); err != nil { + return types.KeyData{}, fmt.Errorf("store key info: %w", err) + } + } + + return types.KeyData{ + SID: p.sessionID, + Type: CGGMP21.String(), + PubKeyBytes: pubKeyBytes, + }, nil +} + +func (p *CGGMP21Session) Sign(ctx context.Context, msg *big.Int) ([]byte, error) { + if p.savedData == nil { + return nil, errors.New("no key loaded") + } + logger.Info("Starting CGGMP21 sign", "walletID", p.sessionID) + + msgHash := msg.Bytes() + var ( + sigResult any + err error + ) + + // Try presign path if store available + if p.presignInfoStore != nil { + sigResult, err = p.signWithPresign(ctx, msgHash) + } else { + sigResult, err = p.signFull(ctx, msgHash) + } + if err != nil { + return nil, err + } + + // Cast and verify + sig, ok := sigResult.(*ecdsa.Signature) + if !ok { + return nil, errors.New("unexpected result type") + } + if !sig.Verify(p.savedData.PublicPoint(), msgHash) { + return nil, errors.New("signature verification failed") + } + return sig.SigEthereum() +} + +func (p *CGGMP21Session) Presign(ctx context.Context, txID string) (bool, error) { + if p.savedData == nil { + return false, errors.New("no key loaded") + } + logger.Info("Starting to presign message CGGMP21", "walletID", p.sessionID, "txID", txID) + result, err := p.run(ctx, cmp.Presign(p.savedData, p.peerIDs, p.workerPool)) + if err != nil { + return false, err + } + presig, ok := result.(*ecdsa.PreSignature) + if !ok { + return false, errors.New("unexpected result type") + } + if err = presig.Validate(); err != nil { + return false, errors.New("presign validation failed") + } + // Store presign in KV using deterministic key including txID + packed, err := cbor.Marshal(presig) + if err != nil { + return false, fmt.Errorf("marshal presign: %w", err) + } + if err := p.kvstore.Put(p.composePresignKey(p.sessionID, txID), packed); err != nil { + return false, fmt.Errorf("store presign: %w", err) + } + return true, nil +} + +func (p *CGGMP21Session) Reshare(ctx context.Context) (res types.ReshareData, err error) { + if p.savedData == nil { + return res, errors.New("no key loaded") + } + cfg, err := p.run(ctx, cmp.Refresh(p.savedData, p.workerPool)) + if err != nil { + return res, err + } + savedData, ok := cfg.(*cmp.Config) + if !ok { + return res, errors.New("unexpected result type") + } + p.savedData = savedData + packed, _ := p.savedData.MarshalBinary() + + key := p.composeKey(p.sessionID) + // Store updated key share + if p.kvstore != nil { + if err := p.kvstore.Put(key, packed); err != nil { + return res, fmt.Errorf("store key: %w", err) + } + } + + // Extract public key coordinates + x, y, err := extractPublicKey(p.savedData.PublicPoint()) + if err != nil { + return res, fmt.Errorf("extract pubkey: %w", err) + } + + // Use secp256k1 curve, not P256 + pubKey := &cryptoEcdsa.PublicKey{ + Curve: btcec.S256(), + X: x, + Y: y, + } + + pubKeyBytes, err := encoding.EncodeS256PubKey(pubKey) + if err != nil { + return res, fmt.Errorf("encode pubkey: %w", err) + } + + return types.ReshareData{ + KeyData: types.KeyData{ + SID: p.sessionID, + Type: CGGMP21.String(), + PubKeyBytes: pubKeyBytes, + }, + Threshold: p.threshold, + }, nil +} + +func (p *CGGMP21Session) composeKey(sid string) string { + return fmt.Sprintf("cggmp21:%s", sid) +} + +func (p *CGGMP21Session) composePresignKey(sid, txID string) string { + return fmt.Sprintf("cggmp21:%s:%s", sid, txID) +} + +// signWithPresign tries to select and use an existing presign for signing. +// If no valid presign exists, falls back to full sign. +func (p *CGGMP21Session) signWithPresign(ctx context.Context, msgHash []byte) (any, error) { + presig, txID := p.selectAndLoadPresign() + if presig == nil || txID == "" { + logger.Debug("No presign found, fallback to full sign", "walletID", p.sessionID) + return p.signFull(ctx, msgHash) + } + + logger.Info("Using presign for signing", "walletID", p.sessionID, "txID", txID) + result, err := p.run(ctx, cmp.PresignOnline(p.savedData, presig, msgHash, p.workerPool)) + if err != nil { + return nil, fmt.Errorf("presign online failed: %w", err) + } + + // Mark and cleanup in background (best effort) + go func() { + if err := p.markPresignUsed(txID); err != nil { + logger.Warn("mark presign used failed", "walletID", p.sessionID, "txID", txID, "err", err) + } + if err := p.deletePresign(txID); err != nil { + logger.Warn("delete presign failed", "walletID", p.sessionID, "txID", txID, "err", err) + } + }() + + return result, nil +} + +// signFull executes a full CGGMP21 signing round. +func (p *CGGMP21Session) signFull(ctx context.Context, msgHash []byte) (any, error) { + logger.Info("Executing full CGGMP21 signing", "walletID", p.sessionID) + result, err := p.run(ctx, cmp.Sign(p.savedData, p.peerIDs, msgHash, p.workerPool)) + if err != nil { + return nil, fmt.Errorf("full sign failed: %w", err) + } + return result, nil +} + +func (p *CGGMP21Session) selectAndLoadPresign() (*ecdsa.PreSignature, string) { + infos, err := p.presignInfoStore.ListPendingPresigns(p.sessionID) + if err != nil || len(infos) == 0 { + return nil, "" + } + + // Filter usable presigns + var filtered []*presigninfo.PresignInfo + for _, inf := range infos { + if inf.Status == presigninfo.PresignStatusActive && + inf.Protocol == types.ProtocolCGGMP21 && + inf.KeyType == types.KeyTypeSecp256k1 { + filtered = append(filtered, inf) + } + } + if len(filtered) == 0 { + return nil, "" + } + + // Sort + pick deterministically + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) { + return filtered[i].TxID < filtered[j].TxID + } + return filtered[i].CreatedAt.Before(filtered[j].CreatedAt) + }) + h := sha256.Sum256([]byte(p.sessionID)) + hashVal := int64(binary.BigEndian.Uint32(h[:4])) + idx := int(hashVal % int64(len(filtered))) + chosen := filtered[idx] + + // Load presign from KV + key := p.composePresignKey(p.sessionID, chosen.TxID) + data, err := p.kvstore.Get(key) + if err != nil || len(data) == 0 { + logger.Warn("presign missing", "walletID", p.sessionID, "txID", chosen.TxID, "err", err) + return nil, "" + } + + presig := ecdsa.EmptyPreSignature(curve.Secp256k1{}) + if err := cbor.Unmarshal(data, presig); err != nil { + logger.Warn("unmarshal presign failed", "walletID", p.sessionID, "txID", chosen.TxID, "err", err) + return nil, "" + } + if err := presig.Validate(); err != nil { + logger.Warn("presign invalid", "walletID", p.sessionID, "txID", chosen.TxID, "err", err) + return nil, "" + } + + logger.Debug("Presign chosen", "walletID", p.sessionID, "txID", chosen.TxID, "idx", idx) + return presig, chosen.TxID +} + +func (p *CGGMP21Session) markPresignUsed(txID string) error { + info, err := p.presignInfoStore.Get(p.sessionID, txID) + if err != nil { + return err + } + now := time.Now() + info.Status = presigninfo.PresignStatusUsed + info.UsedAt = &now + return p.presignInfoStore.Save(p.sessionID, info) +} + +func (p *CGGMP21Session) deletePresign(txID string) error { + return p.kvstore.Delete(p.composePresignKey(p.sessionID, txID)) +} diff --git a/pkg/mpc/taurus/common.go b/pkg/mpc/taurus/common.go new file mode 100644 index 0000000..2ff3f18 --- /dev/null +++ b/pkg/mpc/taurus/common.go @@ -0,0 +1,128 @@ +package taurus + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/types" + "github.com/taurusgroup/multi-party-sig/pkg/math/curve" + "github.com/taurusgroup/multi-party-sig/pkg/party" + "github.com/taurusgroup/multi-party-sig/pkg/protocol" +) + +type Protocol string + +const ( + CGGMP21 Protocol = "cggmp21-ecdsa" // Canetti et al. 2021 (CMP) + FROST Protocol = "frost-schnorr" // FROST Schnorr signatures + FROSTTaproot Protocol = "frost-taproot" // FROST for Bitcoin Taproot +) + +func (p Protocol) String() string { + return string(p) +} + +type TaurusSession interface { + LoadKey(sid string) error + Keygen(ctx context.Context) (types.KeyData, error) + Sign(ctx context.Context, msg *big.Int) ([]byte, error) + Reshare(ctx context.Context) (types.ReshareData, error) + Presign(ctx context.Context, txID string) (bool, error) +} + +type commonSession struct { + sessionID string + threshold int + selfID party.ID + peerIDs party.IDSlice + network *NetworkAdapter + kvstore kvstore.KVStore + keyinfoStore keyinfo.Store +} + +func NewCommonSession( + sessionID string, + selfID party.ID, + peerIDs party.IDSlice, + threshold int, + transport Transport, + kvstore kvstore.KVStore, + keyinfoStore keyinfo.Store, +) *commonSession { + net := NewNetworkAdapter(sessionID, selfID, transport, peerIDs) + return &commonSession{ + sessionID: sessionID, + selfID: selfID, + peerIDs: peerIDs, + threshold: threshold, + network: net, + kvstore: kvstore, + keyinfoStore: keyinfoStore, + } +} + +func (p *commonSession) Presign(ctx context.Context, txID string) (bool, error) { + return false, errors.New("not implemented") +} + +func (p *commonSession) run(ctx context.Context, proto protocol.StartFunc) (any, error) { + h, err := protocol.NewMultiHandler(proto, []byte(p.sessionID)) + if err != nil { + return nil, err + } + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg, ok := <-h.Listen(): + if !ok { + return h.Result() + } + go p.network.Send(msg) + case msg := <-p.network.Next(): + if h.CanAccept(msg) { + h.Accept(msg) + } + } + } +} + +func (p *commonSession) getParticipantPeerIDs() []string { + var ids []string + for _, id := range p.peerIDs { + ids = append(ids, string(id)) + } + return ids +} + +func extractPublicKey(pubPoint curve.Point) (*big.Int, *big.Int, error) { + if pubPoint == nil { + return nil, nil, errors.New("nil public point") + } + + data, err := pubPoint.MarshalBinary() + if err != nil { + return nil, nil, fmt.Errorf("marshal public key: %w", err) + } + + if len(data) == 0 { + return nil, nil, errors.New("empty public key data") + } + + // Use btcec's ParsePubKey which handles both compressed and uncompressed formats + pubKey, err := btcec.ParsePubKey(data) + if err != nil { + return nil, nil, fmt.Errorf("parse public key: %w", err) + } + + // Extract x and y coordinates + x := pubKey.X() + y := pubKey.Y() + + return x, y, nil +} diff --git a/pkg/mpc/taurus/frost.go b/pkg/mpc/taurus/frost.go new file mode 100644 index 0000000..cac8f47 --- /dev/null +++ b/pkg/mpc/taurus/frost.go @@ -0,0 +1,205 @@ +package taurus + +import ( + "context" + cryptoEcdsa "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "math/big" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/fystack/mpcium/pkg/encoding" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/taurusgroup/multi-party-sig/pkg/math/curve" + "github.com/taurusgroup/multi-party-sig/pkg/party" + "github.com/taurusgroup/multi-party-sig/protocols/frost" +) + +type FROSTSession struct { + *commonSession + savedData *frost.Config +} + +func NewFROSTSession( + sessionID string, + selfID party.ID, + peerIDs party.IDSlice, + threshold int, + transport Transport, + kvstore kvstore.KVStore, + keyinfoStore keyinfo.Store, +) TaurusSession { + commonSession := NewCommonSession( + sessionID, + selfID, + peerIDs, + threshold, + transport, + kvstore, + keyinfoStore, + ) + return &FROSTSession{ + commonSession: commonSession, + savedData: nil, + } +} + +func (p *FROSTSession) LoadKey(sid string) error { + key := p.composeKey(sid) + + data, err := p.kvstore.Get(key) + if err != nil { + return fmt.Errorf("load key: %w", err) + } + + cfg := frost.EmptyConfig(curve.Secp256k1{}) + if err := json.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("unmarshal key config: %w", err) + } + + p.savedData = cfg + return nil +} + +func (p *FROSTSession) Keygen(ctx context.Context) (types.KeyData, error) { + logger.Info("Starting to generate key FROST", "walletID", p.sessionID) + + result, err := p.run(ctx, frost.Keygen(curve.Secp256k1{}, p.selfID, p.peerIDs, p.threshold)) + if err != nil { + return types.KeyData{}, err + } + + cfg, ok := result.(*frost.Config) + if !ok { + return types.KeyData{}, fmt.Errorf("unexpected result type %T", result) + } + p.savedData = cfg + + // Extract public key coordinates + x, y, err := extractPublicKey(cfg.PublicKey) + if err != nil { + return types.KeyData{}, fmt.Errorf("extract pubkey: %w", err) + } + + // Use secp256k1 curve, not P256 + pubKey := &cryptoEcdsa.PublicKey{ + Curve: btcec.S256(), + X: x, + Y: y, + } + + pubKeyBytes, err := encoding.EncodeS256PubKey(pubKey) + if err != nil { + return types.KeyData{}, fmt.Errorf("encode pubkey: %w", err) + } + + packed, err := json.Marshal(cfg) + if err != nil { + return types.KeyData{}, fmt.Errorf("marshal config: %w", err) + } + + key := p.composeKey(p.sessionID) + keyInfo := &keyinfo.KeyInfo{ + ParticipantPeerIDs: p.getParticipantPeerIDs(), + Threshold: p.threshold, + Version: 1, + } + + // Store both key and metadata if stores available + if p.kvstore != nil { + if err := p.kvstore.Put(key, packed); err != nil { + return types.KeyData{}, fmt.Errorf("store key: %w", err) + } + } + if p.keyinfoStore != nil { + if err := p.keyinfoStore.Save(key, keyInfo); err != nil { + return types.KeyData{}, fmt.Errorf("store key info: %w", err) + } + } + + return types.KeyData{ + SID: p.sessionID, + Type: FROST.String(), + PubKeyBytes: pubKeyBytes, + }, nil +} + +func (p *FROSTSession) Sign(ctx context.Context, msg *big.Int) ([]byte, error) { + if p.savedData == nil { + return nil, errors.New("no key loaded") + } + logger.Info("Starting to sign message FROST", "walletID", p.sessionID) + msgHash := msg.Bytes() + result, err := p.run(ctx, frost.Sign(p.savedData, p.peerIDs, msgHash)) + if err != nil { + return nil, err + } + + sig, ok := result.(frost.Signature) + if !ok { + return nil, fmt.Errorf("unexpected result type %T", result) + } + + if !sig.Verify(p.savedData.PublicKey, msgHash) { + return nil, errors.New("signature verification failed") + } + return sig.R.MarshalBinary() +} + +func (p *FROSTSession) Reshare(ctx context.Context) (res types.ReshareData, err error) { + if p.savedData == nil { + return res, errors.New("no key loaded") + } + cfg, err := p.run(ctx, frost.Refresh(p.savedData, p.peerIDs)) + if err != nil { + return res, err + } + savedData, ok := cfg.(*frost.Config) + if !ok { + return res, errors.New("unexpected result type") + } + p.savedData = savedData + packed, err := json.Marshal(p.savedData) + if err != nil { + return res, fmt.Errorf("marshal config: %w", err) + } + + key := p.composeKey(p.sessionID) + // Store updated key share + if p.kvstore != nil { + if err := p.kvstore.Put(key, packed); err != nil { + return res, fmt.Errorf("store key: %w", err) + } + } + + // Extract public key coordinates + x, y, err := extractPublicKey(p.savedData.PublicKey) + if err != nil { + return res, fmt.Errorf("extract pubkey: %w", err) + } + + // Use secp256k1 curve, not P256 + pubKey := &cryptoEcdsa.PublicKey{ + Curve: btcec.S256(), + X: x, + Y: y, + } + + pubKeyBytes, err := encoding.EncodeS256PubKey(pubKey) + if err != nil { + return res, fmt.Errorf("encode pubkey: %w", err) + } + + return types.ReshareData{ + KeyData: types.KeyData{SID: p.sessionID, Type: FROST.String(), PubKeyBytes: pubKeyBytes}, + Threshold: p.threshold, + }, nil +} + +func (p *FROSTSession) composeKey(sid string) string { + return fmt.Sprintf("frost:%s", sid) +} diff --git a/pkg/mpc/taurus/nats_transport.go b/pkg/mpc/taurus/nats_transport.go new file mode 100644 index 0000000..88899df --- /dev/null +++ b/pkg/mpc/taurus/nats_transport.go @@ -0,0 +1,169 @@ +package taurus + +import ( + "fmt" + "sync" + "time" + + "github.com/fystack/mpcium/pkg/encoding" + "github.com/fystack/mpcium/pkg/identity" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/messaging" + "github.com/fystack/mpcium/pkg/types" + "github.com/nats-io/nats.go" + "github.com/taurusgroup/multi-party-sig/pkg/party" +) + +type Act string + +const ( + ActKeygen Act = "keygen" + ActSign Act = "sign" + ActReshare Act = "reshare" + ActPresign Act = "presign" +) + +type TopicComposer struct { + ComposeBroadcastTopic func() string + ComposeDirectTopic func(to string, walletID string) string +} + +type NATSTransport struct { + selfID string + wallet string + act Act + proto Protocol + topicComposer *TopicComposer + pubsub messaging.PubSub + direct messaging.DirectMessaging + identityStore identity.Store + inbox chan types.TaurusMessage + done chan struct{} + subs []messaging.Subscription + closeMu sync.Once +} + +func NewNATSTransport( + walletID string, + self party.ID, + act Act, + proto Protocol, + pubsub messaging.PubSub, + direct messaging.DirectMessaging, + identityStore identity.Store, +) *NATSTransport { + t := &NATSTransport{ + selfID: string(self), + wallet: walletID, + act: act, + proto: proto, + pubsub: pubsub, + direct: direct, + identityStore: identityStore, + topicComposer: &TopicComposer{ + ComposeBroadcastTopic: func() string { + return fmt.Sprintf("%s:broadcast:%s:%s", act, proto, walletID) + }, + ComposeDirectTopic: func(to string, walletID string) string { + return fmt.Sprintf("%s:direct:%s:%s:%s", act, proto, to, walletID) + }, + }, + inbox: make(chan types.TaurusMessage, 128), + done: make(chan struct{}), + } + + bcastTopic := t.topicComposer.ComposeBroadcastTopic() + if sub, err := pubsub.Subscribe(bcastTopic, func(msg *nats.Msg) { + t.handle(msg.Data) + }); err == nil { + t.subs = append(t.subs, sub) + } + + directTopic := t.topicComposer.ComposeDirectTopic(t.selfID, walletID) + if sub, err := direct.Listen(directTopic, t.handle); err == nil { + t.subs = append(t.subs, sub) + } + + logger.Debug( + "NATS Transport listening", + "wallet", + walletID, + "broadcast", + bcastTopic, + "direct", + directTopic, + ) + return t +} + +func (t *NATSTransport) Send(to string, msg types.TaurusMessage) error { + // use AEAD encryption for each message so NATs server learns nothing + if t.identityStore != nil { + cipher, err := t.identityStore.SignTaurusMessage(&msg) + if err != nil { + return err + } + msg.Signature = cipher + } + data, err := encoding.StructToJsonBytes(&msg) + if err != nil { + return err + } + if msg.IsBroadcast { + topic := t.topicComposer.ComposeBroadcastTopic() + return t.pubsub.Publish(topic, data) + } + + // Use direct messaging for unicast with retry + topic := t.topicComposer.ComposeDirectTopic(to, t.wallet) + if to == t.selfID { + return t.direct.SendToSelf(topic, data) + } + + return t.direct.SendToOtherWithRetry(topic, data, messaging.RetryConfig{ + RetryAttempt: 3, + ExponentialBackoff: true, + Delay: 50 * time.Millisecond, + OnRetry: func(n uint, err error) { + logger.Warn("Retry sending", "to", to, "attempt", n+1, "err", err.Error()) + }, + }) +} + +func (t *NATSTransport) Inbox() <-chan types.TaurusMessage { return t.inbox } +func (t *NATSTransport) Done() <-chan struct{} { return t.done } + +func (t *NATSTransport) Close() error { + t.closeMu.Do(func() { + for _, sub := range t.subs { + if sub != nil { + _ = sub.Unsubscribe() + } + } + close(t.inbox) + close(t.done) + logger.Debug("NATSTransport closed", "wallet", t.wallet) + }) + return nil +} + +func (t *NATSTransport) handle(data []byte) { + var msg types.TaurusMessage + if err := encoding.JsonBytesToStruct(data, &msg); err != nil { + return + } + if t.identityStore != nil { + if err := t.identityStore.VerifyTaurusMessage(&msg); err != nil { + logger.Warn("failed to verify message", "err", err.Error()) + return + } + } + if msg.From == t.selfID { + return + } + select { + case t.inbox <- msg: + default: + logger.Warn("dropping inbound message, inbox full", "wallet", t.wallet) + } +} diff --git a/pkg/mpc/taurus/taproot.go b/pkg/mpc/taurus/taproot.go new file mode 100644 index 0000000..5865436 --- /dev/null +++ b/pkg/mpc/taurus/taproot.go @@ -0,0 +1,216 @@ +package taurus + +import ( + "context" + cryptoEcdsa "crypto/ecdsa" + "errors" + "fmt" + "math/big" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/fxamacker/cbor/v2" + "github.com/fystack/mpcium/pkg/encoding" + "github.com/fystack/mpcium/pkg/keyinfo" + "github.com/fystack/mpcium/pkg/kvstore" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/taurusgroup/multi-party-sig/pkg/math/curve" + "github.com/taurusgroup/multi-party-sig/pkg/party" + "github.com/taurusgroup/multi-party-sig/pkg/taproot" + "github.com/taurusgroup/multi-party-sig/protocols/frost" +) + +type TaprootSession struct { + *commonSession + savedData *frost.TaprootConfig +} + +func NewTaprootSession( + sessionID string, + selfID party.ID, + peerIDs party.IDSlice, + threshold int, + transport Transport, + kvstore kvstore.KVStore, + keyinfoStore keyinfo.Store, +) TaurusSession { + commonSession := NewCommonSession( + sessionID, + selfID, + peerIDs, + threshold, + transport, + kvstore, + keyinfoStore, + ) + return &TaprootSession{ + commonSession: commonSession, + savedData: nil, + } +} + +func (p *TaprootSession) LoadKey(sid string) error { + key := p.composeKey(sid) + + data, err := p.kvstore.Get(key) + if err != nil { + return fmt.Errorf("load key: %w", err) + } + + cfg := &frost.TaprootConfig{} + if err := cbor.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("unmarshal key config: %w", err) + } + + p.savedData = cfg + return nil +} + +func (p *TaprootSession) Keygen(ctx context.Context) (types.KeyData, error) { + logger.Info("Starting to generate key Taproot", "walletID", p.sessionID) + + result, err := p.run(ctx, frost.KeygenTaproot(p.selfID, p.peerIDs, p.threshold)) + if err != nil { + return types.KeyData{}, err + } + + cfg, ok := result.(*frost.TaprootConfig) + if !ok { + return types.KeyData{}, fmt.Errorf("unexpected result type %T", result) + } + p.savedData = cfg + + pubPoint, err := curve.Secp256k1{}.LiftX(cfg.PublicKey) + if err != nil { + return types.KeyData{}, fmt.Errorf("lift pubkey: %w", err) + } + // Extract public key coordinates + x, y, err := extractPublicKey(pubPoint) + if err != nil { + return types.KeyData{}, fmt.Errorf("extract pubkey: %w", err) + } + + // Use secp256k1 curve, not P256 + pubKey := &cryptoEcdsa.PublicKey{ + Curve: btcec.S256(), + X: x, + Y: y, + } + + pubKeyBytes, err := encoding.EncodeS256PubKey(pubKey) + if err != nil { + return types.KeyData{}, fmt.Errorf("encode pubkey: %w", err) + } + + packed, err := cbor.Marshal(cfg) + if err != nil { + return types.KeyData{}, fmt.Errorf("marshal config: %w", err) + } + + key := p.composeKey(p.sessionID) + keyInfo := &keyinfo.KeyInfo{ + ParticipantPeerIDs: p.getParticipantPeerIDs(), + Threshold: p.threshold, + Version: 1, + } + + // Store both key and metadata if stores available + if p.kvstore != nil { + if err := p.kvstore.Put(key, packed); err != nil { + return types.KeyData{}, fmt.Errorf("store key: %w", err) + } + } + if p.keyinfoStore != nil { + if err := p.keyinfoStore.Save(key, keyInfo); err != nil { + return types.KeyData{}, fmt.Errorf("store key info: %w", err) + } + } + + return types.KeyData{ + SID: p.sessionID, + Type: FROSTTaproot.String(), + PubKeyBytes: pubKeyBytes, + }, nil +} + +func (p *TaprootSession) Sign(ctx context.Context, msg *big.Int) ([]byte, error) { + if p.savedData == nil { + return nil, errors.New("no key loaded") + } + logger.Info("Starting to sign message Taproot", "walletID", p.sessionID) + msgHash := msg.Bytes() + result, err := p.run(ctx, frost.SignTaproot(p.savedData, p.peerIDs, msgHash)) + if err != nil { + return nil, err + } + sig, ok := result.(taproot.Signature) + if !ok { + return nil, errors.New("unexpected result type") + } + if !p.savedData.PublicKey.Verify(sig, msgHash) { + return nil, errors.New("signature verification failed") + } + return []byte(sig), nil +} + +func (p *TaprootSession) Reshare(ctx context.Context) (res types.ReshareData, err error) { + if p.savedData == nil { + return res, errors.New("no key loaded") + } + cfg, err := p.run(ctx, frost.RefreshTaproot(p.savedData, p.peerIDs)) + if err != nil { + return res, err + } + savedData, ok := cfg.(*frost.TaprootConfig) + if !ok { + return res, errors.New("unexpected result type") + } + p.savedData = savedData + packed, err := cbor.Marshal(p.savedData) + if err != nil { + return res, fmt.Errorf("marshal config: %w", err) + } + + key := p.composeKey(p.sessionID) + // Store updated key share + if p.kvstore != nil { + if err := p.kvstore.Put(key, packed); err != nil { + return res, fmt.Errorf("store key: %w", err) + } + } + + // Extract public key coordinates + pubPoint, err := curve.Secp256k1{}.LiftX(p.savedData.PublicKey) + if err != nil { + return res, fmt.Errorf("lift pubkey: %w", err) + } + x, y, err := extractPublicKey(pubPoint) + if err != nil { + return res, fmt.Errorf("extract pubkey: %w", err) + } + + // Use secp256k1 curve, not P256 + pubKey := &cryptoEcdsa.PublicKey{ + Curve: btcec.S256(), + X: x, + Y: y, + } + + pubKeyBytes, err := encoding.EncodeS256PubKey(pubKey) + if err != nil { + return res, fmt.Errorf("encode pubkey: %w", err) + } + + return types.ReshareData{ + KeyData: types.KeyData{ + SID: p.sessionID, + Type: CGGMP21.String(), + PubKeyBytes: pubKeyBytes, + }, + Threshold: p.threshold, + }, nil +} + +func (p *TaprootSession) composeKey(sid string) string { + return fmt.Sprintf("taproot:%s", sid) +} diff --git a/pkg/mpc/taurus/taurus_test.go b/pkg/mpc/taurus/taurus_test.go new file mode 100644 index 0000000..0d09dc4 --- /dev/null +++ b/pkg/mpc/taurus/taurus_test.go @@ -0,0 +1,102 @@ +package taurus + +import ( + "bytes" + "context" + "math/big" + "sync" + "testing" + + "github.com/fystack/mpcium/pkg/logger" + "github.com/taurusgroup/multi-party-sig/pkg/party" +) + +// taurusTest represents a 2-party in-memory network for Taurus +type taurusTest struct { + parties []TaurusSession + results map[string]chan any +} + +func newTaurusTest(sid string, ids []party.ID) *taurusTest { + t := &taurusTest{ + results: map[string]chan any{ + "keygen": make(chan any, len(ids)), + "sign": make(chan any, len(ids)), + }, + } + + transports := make([]*Memory, len(ids)) + for i, id := range ids { + transports[i] = NewMemoryParty(string(id)) + } + LinkPeers(transports...) + + for i, id := range ids { + t.parties = append(t.parties, + NewTaprootSession(sid, id, ids, 1, transports[i], nil, nil)) + } + + return t +} + +func (t *taurusTest) runAll(fn func(TaurusSession) (any, error), key string) { + var wg sync.WaitGroup + for _, p := range t.parties { + wg.Add(1) + go func(p TaurusSession) { + defer wg.Done() + + res, err := fn(p) + if err != nil { + logger.Error("operation failed", err) + return + } + t.results[key] <- res + }(p) + } + wg.Wait() + close(t.results[key]) +} + +func drain[T any](ch chan any) []T { + out := make([]T, 0, len(ch)) + for v := range ch { + out = append(out, v.(T)) + } + return out +} + +func assertAllBytesEqual(t *testing.T, vals [][]byte) { + if len(vals) == 0 { + t.Fatal("no values to compare") + } + first := vals[0] + for i, v := range vals[1:] { + if !bytes.Equal(first, v) { + t.Fatalf("byte slices not equal at index %d", i+1) + } + } +} + +func TestTaurusParty(t *testing.T) { + t.Parallel() + + // quick test, 2 nodes only + ids := []party.ID{"node0", "node1"} + sid := "cggmp21-fast" + test := newTaurusTest(sid, ids) + + // --- Keygen (cached) --- + test.runAll(func(p TaurusSession) (any, error) { + return p.Keygen(context.Background()) + }, "keygen") + + // --- Sign --- + msg := big.NewInt(42) + test.runAll(func(p TaurusSession) (any, error) { + return p.Sign(context.Background(), msg) + }, "sign") + + sigs := drain[[]byte](test.results["sign"]) + assertAllBytesEqual(t, sigs) +} diff --git a/pkg/mpc/taurus/transport.go b/pkg/mpc/taurus/transport.go new file mode 100644 index 0000000..1c4034d --- /dev/null +++ b/pkg/mpc/taurus/transport.go @@ -0,0 +1,79 @@ +package taurus + +import ( + "sync" + + "github.com/fystack/mpcium/pkg/types" +) + +type Transport interface { + Send(to string, msg types.TaurusMessage) error + Inbox() <-chan types.TaurusMessage + Done() <-chan struct{} + Close() error +} + +// Memory implements Transport for local testing (per-party instance) +type Memory struct { + selfID string + peers map[string]*Memory // reference to peers + mu sync.RWMutex + + inbox chan types.TaurusMessage + done chan struct{} +} + +// NewMemoryParty creates a new memory transport for a party +func NewMemoryParty(selfID string) *Memory { + return &Memory{ + selfID: selfID, + peers: make(map[string]*Memory), + inbox: make(chan types.TaurusMessage, 100), + done: make(chan struct{}), + } +} + +// LinkPeers links all parties together (must be called after all parties are created) +func LinkPeers(parties ...*Memory) { + for _, p := range parties { + for _, q := range parties { + if p.selfID == q.selfID { + continue + } + p.peers[q.selfID] = q + } + } +} + +func (m *Memory) SelfID() string { + return m.selfID +} + +func (m *Memory) Send(to string, msg types.TaurusMessage) error { + m.mu.RLock() + peer, ok := m.peers[to] + m.mu.RUnlock() + if !ok { + return nil + } + select { + case peer.inbox <- msg: + default: + // drop if inbox full + } + return nil +} + +func (m *Memory) Inbox() <-chan types.TaurusMessage { + return m.inbox +} + +func (m *Memory) Done() <-chan struct{} { + return m.done +} + +func (m *Memory) Close() error { + close(m.done) + close(m.inbox) + return nil +} diff --git a/pkg/presign/presign.go b/pkg/presign/presign.go new file mode 100644 index 0000000..59063db --- /dev/null +++ b/pkg/presign/presign.go @@ -0,0 +1,260 @@ +package presign + +import ( + "context" + "sync" + "time" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/presigninfo" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "golang.org/x/sync/semaphore" +) + +type Config struct { + MinPoolSize int + MaxPoolSize int + GlobalMaxConcurrency int + HotWindowDuration time.Duration + RefillInterval time.Duration + ThrottleDelay time.Duration +} + +var DefaultConfig = Config{ + MinPoolSize: 5, + MaxPoolSize: 20, + GlobalMaxConcurrency: 10, + HotWindowDuration: 5 * time.Minute, + RefillInterval: 15 * time.Second, + ThrottleDelay: 5 * time.Second, +} + +type walletState struct { + lastTouch time.Time + pendingCount int +} + +type PresignPool struct { + cfg *Config + ctx context.Context + cancel context.CancelFunc + client client.MPCClient + infoStore presigninfo.Store + + wg sync.WaitGroup + mu sync.RWMutex + wallets map[string]*walletState + globalSem *semaphore.Weighted +} + +func NewPresignPool(cfg *Config, client client.MPCClient, infoStore presigninfo.Store) *PresignPool { + if cfg == nil { + tmp := DefaultConfig + cfg = &tmp + } + ctx, cancel := context.WithCancel(context.Background()) + p := &PresignPool{ + cfg: cfg, + client: client, + infoStore: infoStore, + ctx: ctx, + cancel: cancel, + wallets: make(map[string]*walletState), + globalSem: semaphore.NewWeighted(int64(cfg.GlobalMaxConcurrency)), + } + + // Subscribe to presign completion + if err := p.client.OnPresignResult(func(evt event.PresignResultEvent) { + p.handlePresignResult(evt.WalletID, evt.TxID, evt.ResultType == event.ResultTypeSuccess) + }); err != nil { + logger.Error("[PRESIGN] subscribe handler failed", err) + } + return p +} + +func (p *PresignPool) Start(ctx context.Context) { + logger.Info("[PRESIGN] Pool started") + p.wg.Add(1) + go p.mainLoop() + go func() { + <-ctx.Done() + p.Stop() + }() +} + +func (p *PresignPool) Stop() { + p.cancel() + p.wg.Wait() + logger.Info("[PRESIGN] Pool stopped") +} + +func (p *PresignPool) TouchHot(walletID string) { + p.mu.Lock() + defer p.mu.Unlock() + if state, ok := p.wallets[walletID]; ok { + state.lastTouch = time.Now() + } else { + p.wallets[walletID] = &walletState{lastTouch: time.Now()} + } +} + +func (p *PresignPool) mainLoop() { + defer p.wg.Done() + ticker := time.NewTicker(p.cfg.RefillInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + p.refillAll() + case <-p.ctx.Done(): + return + } + } +} + +func (p *PresignPool) refillAll() { + for _, walletID := range p.getHotWallets() { + select { + case <-p.ctx.Done(): + return + default: + p.refillWallet(walletID) + time.Sleep(2 * time.Second) + } + } +} + +func (p *PresignPool) refillWallet(walletID string) { + list, err := p.infoStore.ListPendingPresigns(walletID) + if err != nil { + logger.Warn("[PRESIGN] list presigns failed", "wallet", walletID, "err", err) + return + } + + activeCount := 0 + for _, info := range list { + if info.Status == presigninfo.PresignStatusActive { + activeCount++ + } + } + + p.mu.RLock() + pendingCount := 0 + if st := p.wallets[walletID]; st != nil { + pendingCount = st.pendingCount + } + p.mu.RUnlock() + + total := activeCount + pendingCount + if total >= p.cfg.MinPoolSize { + return + } + if pendingCount > 0 { + return + } + + p.incrementPending(walletID) + go p.requestPresign(walletID) +} + +func (p *PresignPool) requestPresign(walletID string) { + if err := p.globalSem.Acquire(p.ctx, 1); err != nil { + logger.Warn("[PRESIGN] semaphore acquire failed", "wallet", walletID, "err", err) + p.decrementPending(walletID) + return + } + defer p.globalSem.Release(1) + + time.Sleep(p.cfg.ThrottleDelay) + + txID := "presign_" + uuid.NewString() + req := &types.PresignTxMessage{ + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolCGGMP21, + WalletID: walletID, + TxID: txID, + } + if err := p.client.PresignTransaction(req); err != nil { + logger.Warn("[PRESIGN] presign publish failed", "wallet", walletID, "tx", txID, "err", err) + p.decrementPending(walletID) + return + } + logger.Debug("[PRESIGN] presign sent", "wallet", walletID, "tx", txID) +} + +func (p *PresignPool) handlePresignResult(walletID, txID string, success bool) { + p.decrementPending(walletID) + + if !success { + logger.Warn("[PRESIGN] presign failed", "wallet", walletID, "tx", txID) + _ = p.infoStore.Delete(walletID, txID) // cleanup failed presign + return + } + + info := &presigninfo.PresignInfo{ + TxID: txID, + WalletID: walletID, + KeyType: types.KeyTypeSecp256k1, + Protocol: types.ProtocolCGGMP21, + Status: presigninfo.PresignStatusActive, + CreatedAt: time.Now(), + } + if err := p.infoStore.Save(walletID, info); err != nil { + logger.Warn("[PRESIGN] save failed", "wallet", walletID, "tx", txID, "err", err) + return + } + + // Clean up expired/used presigns + p.cleanupUsed(walletID) + logger.Debug("[PRESIGN] presign done", "wallet", walletID, "tx", txID) +} + +func (p *PresignPool) cleanupUsed(walletID string) { + list, err := p.infoStore.ListPendingPresigns(walletID) + if err != nil { + return + } + for _, inf := range list { + if inf.Status == presigninfo.PresignStatusUsed { + _ = p.infoStore.Delete(walletID, inf.TxID) + logger.Debug("[PRESIGN] cleaned used presign", "wallet", walletID, "tx", inf.TxID) + } + } +} + +func (p *PresignPool) getHotWallets() []string { + now := time.Now() + p.mu.RLock() + defer p.mu.RUnlock() + hot := make([]string, 0, len(p.wallets)) + for id, st := range p.wallets { + if now.Sub(st.lastTouch) < p.cfg.HotWindowDuration { + hot = append(hot, id) + } + } + return hot +} + +func (p *PresignPool) incrementPending(walletID string) { + p.mu.Lock() + defer p.mu.Unlock() + if st, ok := p.wallets[walletID]; ok { + st.pendingCount++ + } else { + p.wallets[walletID] = &walletState{lastTouch: time.Now(), pendingCount: 1} + } +} + +func (p *PresignPool) decrementPending(walletID string) { + p.mu.Lock() + defer p.mu.Unlock() + if st, ok := p.wallets[walletID]; ok && st.pendingCount > 0 { + st.pendingCount-- + } +} + +func (p *PresignPool) GetHotWalletsSnapshot() []string { return p.getHotWallets() } diff --git a/pkg/presigninfo/presigninfo.go b/pkg/presigninfo/presigninfo.go new file mode 100644 index 0000000..059b204 --- /dev/null +++ b/pkg/presigninfo/presigninfo.go @@ -0,0 +1,139 @@ +package presigninfo + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/fystack/mpcium/pkg/infra" + "github.com/fystack/mpcium/pkg/types" + "github.com/hashicorp/consul/api" +) + +const ( + PresignStatusActive = "active" + PresignStatusUsed = "used" +) + +type PresignInfo struct { + TxID string `json:"tx_id"` + WalletID string `json:"wallet_id"` + KeyType types.KeyType `json:"key_type"` + Protocol types.Protocol `json:"protocol"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UsedAt *time.Time `json:"used_at,omitempty"` +} + +type store struct { + consulKV infra.ConsulKV +} + +func NewStore(consulKV infra.ConsulKV) *store { + return &store{consulKV: consulKV} +} + +type Store interface { + Get(walletID string, txID string) (*PresignInfo, error) + Save(walletID string, info *PresignInfo) error + Delete(walletID string, txID string) error + ListPendingPresigns(walletID string) ([]*PresignInfo, error) + ListAllWallets() ([]string, error) +} + +func (s *store) Get(walletID string, txID string) (*PresignInfo, error) { + pair, _, err := s.consulKV.Get(s.composeKey(walletID, txID), nil) + if err != nil { + return nil, fmt.Errorf("Failed to get presign info: %w", err) + } + if pair == nil { + return nil, fmt.Errorf("Presign info not found") + } + + info := &PresignInfo{} + err = json.Unmarshal(pair.Value, info) + if err != nil { + return nil, fmt.Errorf("Failed to unmarshal presign info: %w", err) + } + + return info, nil +} + +func (s *store) Save(walletID string, info *PresignInfo) error { + bytes, err := json.Marshal(info) + if err != nil { + return fmt.Errorf("failed to marshal presign info: %w", err) + } + + pair := &api.KVPair{ + Key: s.composeKey(walletID, info.TxID), + Value: bytes, + } + + _, err = s.consulKV.Put(pair, nil) + if err != nil { + return fmt.Errorf("Failed to save presign info: %w", err) + } + + return nil +} + +func (s *store) Delete(walletID string, txID string) error { + _, err := s.consulKV.Delete(s.composeKey(walletID, txID), nil) + if err != nil { + return fmt.Errorf("Failed to delete presign info: %w", err) + } + return nil +} + +// ListPendingPresigns returns all pending presigns for a given wallet ID +func (s *store) ListPendingPresigns(walletID string) ([]*PresignInfo, error) { + entries, _, err := s.consulKV.List(s.composeKey(walletID, ""), nil) + if err != nil { + return nil, fmt.Errorf("Failed to list presign info: %w", err) + } + infos := make([]*PresignInfo, 0, len(entries)) + for _, entry := range entries { + info := &PresignInfo{} + if err := json.Unmarshal(entry.Value, info); err != nil { + return nil, fmt.Errorf("Failed to unmarshal presign info: %w", err) + } + if info.TxID != "" { + infos = append(infos, info) + } + } + return infos, nil +} + +// ListAllWallets returns all wallet IDs that have presigns in Consul KV +func (s *store) ListAllWallets() ([]string, error) { + entries, _, err := s.consulKV.List("presign_info/", nil) + if err != nil { + return nil, fmt.Errorf("Failed to list all presign info: %w", err) + } + + walletMap := make(map[string]bool) + for _, entry := range entries { + // Key format: presign_info/{walletID}/{txID} + // Extract walletID from the key + key := entry.Key + parts := strings.Split(key, "/") + if len(parts) >= 2 && parts[0] == "presign_info" { + walletID := parts[1] + if walletID != "" { + walletMap[walletID] = true + } + } + } + + wallets := make([]string, 0, len(walletMap)) + for walletID := range walletMap { + wallets = append(wallets, walletID) + } + return wallets, nil +} + +func (s *store) composeKey(walletID string, txID string) string { + return fmt.Sprintf("presign_info/%s/%s", walletID, txID) +} diff --git a/pkg/types/initiator_msg.go b/pkg/types/initiator_msg.go index d770e79..8dd1f82 100644 --- a/pkg/types/initiator_msg.go +++ b/pkg/types/initiator_msg.go @@ -1,6 +1,17 @@ package types -import "encoding/json" +import ( + "encoding/json" + "errors" + "fmt" +) + +type EventInitiatorKeyType string + +const ( + EventInitiatorKeyTypeEd25519 EventInitiatorKeyType = "ed25519" + EventInitiatorKeyTypeP256 EventInitiatorKeyType = "p256" +) type KeyType string @@ -9,35 +20,77 @@ const ( KeyTypeEd25519 KeyType = "ed25519" ) -type EventInitiatorKeyType string +type Protocol string const ( - EventInitiatorKeyTypeEd25519 EventInitiatorKeyType = "ed25519" - EventInitiatorKeyTypeP256 EventInitiatorKeyType = "p256" + ProtocolGG18 Protocol = "gg18" + ProtocolCGGMP21 Protocol = "cggmp21" + ProtocolFROST Protocol = "frost" + ProtocolTaproot Protocol = "taproot" ) +func (p Protocol) String() string { + return string(p) +} + +// mapping of key types → supported protocols +var supportedProtocols = map[KeyType][]Protocol{ + KeyTypeSecp256k1: { + ProtocolGG18, + ProtocolCGGMP21, + ProtocolFROST, + ProtocolTaproot, + }, + KeyTypeEd25519: { + ProtocolGG18, + }, +} + +// ValidateKeyProtocol checks if a key type supports a given protocol. +func ValidateKeyProtocol(keyType KeyType, protocol Protocol) error { + if keyType == "" || protocol == "" { + return errors.New("key_type and protocol are required") + } + + supported, ok := supportedProtocols[keyType] + if !ok { + return fmt.Errorf("unsupported key_type %q", keyType) + } + + for _, p := range supported { + if p == protocol { + return nil // valid combo + } + } + + return fmt.Errorf( + "protocol %q not supported for key_type %q; expected one of %v", + protocol, keyType, supported, + ) +} + // InitiatorMessage is anything that carries a payload to verify and its signature. type InitiatorMessage interface { - // Raw returns the canonical byte‐slice that was signed. Raw() ([]byte, error) - // Sig returns the signature over Raw(). Sig() []byte - // InitiatorID returns the ID whose public key we have to look up. InitiatorID() string } type GenerateKeyMessage struct { - WalletID string `json:"wallet_id"` - Signature []byte `json:"signature"` + WalletID string `json:"wallet_id"` + ECDSAProtocol Protocol `json:"ecdsa_protocol,omitempty"` + EdDSAProtocol Protocol `json:"eddsa_protocol,omitempty"` + Signature []byte `json:"signature"` } type SignTxMessage struct { - KeyType KeyType `json:"key_type"` - WalletID string `json:"wallet_id"` - NetworkInternalCode string `json:"network_internal_code"` - TxID string `json:"tx_id"` - Tx []byte `json:"tx"` - Signature []byte `json:"signature"` + KeyType KeyType `json:"key_type"` + Protocol Protocol `json:"protocol,omitempty"` + WalletID string `json:"wallet_id"` + NetworkInternalCode string `json:"network_internal_code"` + TxID string `json:"tx_id"` + Tx []byte `json:"tx"` + Signature []byte `json:"signature"` } type ResharingMessage struct { @@ -45,12 +98,41 @@ type ResharingMessage struct { NodeIDs []string `json:"node_ids"` // new peer IDs NewThreshold int `json:"new_threshold"` KeyType KeyType `json:"key_type"` + Protocol Protocol `json:"protocol,omitempty"` WalletID string `json:"wallet_id"` Signature []byte `json:"signature,omitempty"` } +type PresignTxMessage struct { + KeyType KeyType `json:"key_type"` + Protocol Protocol `json:"protocol"` + WalletID string `json:"wallet_id"` + TxID string `json:"tx_id"` + Signature []byte `json:"signature"` +} + +func (m *GenerateKeyMessage) Raw() ([]byte, error) { + payload := struct { + WalletID string `json:"wallet_id"` + ECDSAProtocol Protocol `json:"ecdsa_protocol,omitempty"` + EdDSAProtocol Protocol `json:"eddsa_protocol,omitempty"` + }{ + WalletID: m.WalletID, + ECDSAProtocol: m.ECDSAProtocol, + EdDSAProtocol: m.EdDSAProtocol, + } + return json.Marshal(payload) +} + +func (m *GenerateKeyMessage) Sig() []byte { + return m.Signature +} + +func (m *GenerateKeyMessage) InitiatorID() string { + return m.WalletID +} + func (m *SignTxMessage) Raw() ([]byte, error) { - // omit the Signature field itself when computing the signed‐over data payload := struct { KeyType KeyType `json:"key_type"` WalletID string `json:"wallet_id"` @@ -75,28 +157,39 @@ func (m *SignTxMessage) InitiatorID() string { return m.TxID } -func (m *GenerateKeyMessage) Raw() ([]byte, error) { - return []byte(m.WalletID), nil +func (m *ResharingMessage) Raw() ([]byte, error) { + copy := *m + copy.Signature = nil + return json.Marshal(©) } -func (m *GenerateKeyMessage) Sig() []byte { +func (m *ResharingMessage) Sig() []byte { return m.Signature } -func (m *GenerateKeyMessage) InitiatorID() string { +func (m *ResharingMessage) InitiatorID() string { return m.WalletID } -func (m *ResharingMessage) Raw() ([]byte, error) { - copy := *m // create a shallow copy - copy.Signature = nil // modify only the copy - return json.Marshal(©) +func (m *PresignTxMessage) Raw() ([]byte, error) { + payload := struct { + KeyType KeyType `json:"key_type"` + Protocol Protocol `json:"protocol"` + WalletID string `json:"wallet_id"` + TxID string `json:"tx_id"` + }{ + KeyType: m.KeyType, + Protocol: m.Protocol, + WalletID: m.WalletID, + TxID: m.TxID, + } + return json.Marshal(payload) } -func (m *ResharingMessage) Sig() []byte { +func (m *PresignTxMessage) Sig() []byte { return m.Signature } -func (m *ResharingMessage) InitiatorID() string { +func (m *PresignTxMessage) InitiatorID() string { return m.WalletID } diff --git a/pkg/types/taurus.go b/pkg/types/taurus.go new file mode 100644 index 0000000..d37b67d --- /dev/null +++ b/pkg/types/taurus.go @@ -0,0 +1,45 @@ +package types + +import "encoding/json" + +// Message represents a protocol message +type TaurusMessage struct { + SID string + From string + To []string + IsBroadcast bool + Data []byte + Signature []byte +} + +func (m *TaurusMessage) MarshalForSigning() ([]byte, error) { + // Exclude the Signature field from the signed payload to ensure deterministic signatures + type signPayload struct { + SID string `json:"sid"` + From string `json:"from"` + To []string `json:"to"` + IsBroadcast bool `json:"isBroadcast"` + Data []byte `json:"data"` + } + sp := signPayload{ + SID: m.SID, + From: m.From, + To: m.To, + IsBroadcast: m.IsBroadcast, + Data: m.Data, + } + return json.Marshal(sp) +} + +// KeyData represents the result of key generation +type KeyData struct { + SID string + Type string + Payload []byte + PubKeyBytes []byte +} + +type ReshareData struct { + KeyData + Threshold int +} diff --git a/setup_identities.sh b/setup_identities.sh index 708c961..e2f5979 100755 --- a/setup_identities.sh +++ b/setup_identities.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Add Go bin directory to PATH to ensure mpcium-cli is available +export PATH="$HOME/go/bin:$PATH" + # Number of nodes to create (default is 3) NUM_NODES=3