From 5826501eaf6c374f9d0041cbeb66b9b47819cfcb Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Fri, 14 Jun 2024 02:48:04 +1000 Subject: [PATCH] popmd/wasm: use structs instead of custom 'Object' type --- web/popminer/api.go | 144 ++++++++++++++++++++++++++++++ web/popminer/dispatch.go | 158 ++++++++++++++++----------------- web/popminer/util.go | 184 +++++++++++++++++++++++++++++++++------ web/www/index.html | 4 +- 4 files changed, 376 insertions(+), 114 deletions(-) create mode 100644 web/popminer/api.go diff --git a/web/popminer/api.go b/web/popminer/api.go new file mode 100644 index 00000000..3911eba2 --- /dev/null +++ b/web/popminer/api.go @@ -0,0 +1,144 @@ +package main + +// Method represents a method that can be dispatched. +type Method string + +const ( + // The following can be dispatched at any time. + MethodVersion Method = "version" // Retrieve WASM version information + MethodGenerateKey Method = "generateKey" // Generate secp256k1 key pair + MethodStartPoPMiner Method = "startPoPMiner" // Start PoP Miner + MethodStopPoPMiner Method = "stopPoPMiner" // Stop PoP Miner + + // The following can only be dispatched after the PoP Miner is running. + MethodPing Method = "ping" // Ping BFG + MethodL2Keystones Method = "l2Keystones" // Retrieve L2 keystones + MethodBitcoinBalance Method = "bitcoinBalance" // Retrieve bitcoin balance + MethodBitcoinInfo Method = "bitcoinInfo" // Retrieve bitcoin information + MethodBitcoinUTXOs Method = "bitcoinUTXOs" // Retrieve bitcoin UTXOs +) + +// Error represents an error that has occurred within the WASM PoP Miner. +type Error struct { + // Message is the error message. + Message string `json:"message"` + + // Stack is the Go debug stack (from debug.Stack()) for the error. + Stack string `json:"stack"` + + // Timestamp is the time the error occurred, in unix seconds. + Timestamp int64 `json:"timestamp"` +} + +// VersionResult contains version information for the WASM PoP Miner. +// Returned by MethodVersion. +type VersionResult struct { + // Version is the version of the WASM PoP Miner. + Version string `json:"version"` + + // GitCommit is the SHA-1 hash of the Git commit the WASM binary was built + // from. The value should be the same as the output from git rev-parse HEAD. + GitCommit string `json:"gitCommit"` +} + +// GenerateKeyResult contains the generated key information. +// Returned by MethodGenerateKey. +type GenerateKeyResult struct { + // EthereumAddress is the Ethereum address for the generated key. + EthereumAddress string `json:"ethereumAddress"` + + // Network is the network for which the key was generated. + Network string `json:"network"` + + // PrivateKey is the generated secpk256k1 private key, encoded as a + // hexadecimal string. + PrivateKey string `json:"privateKey"` + + // PublicKey is the generated secp256k1 public key, in the 33-byte + // compressed format, encoded as a hexadecimal string. + PublicKey string `json:"publicKey"` + + // PublicKeyHash is the Bitcoin pay-to-pubkey-hash address for the generated + // key. + PublicKeyHash string `json:"publicKeyHash"` +} + +// PingResult contains information when pinging the BFG server. +// Returned by MethodPing. +type PingResult struct { + // OriginTimestamp is the time the PoP Miner sent the ping request to BFG, + // in unix nanoseconds. + OriginTimestamp int64 `json:"originTimestamp"` + + // Timestamp is the time the BFG server sent the ping response, in unix + // nanoseconds. + Timestamp int64 `json:"timestamp"` +} + +// L2KeystoneResult contains the requested l2 keystones. +// Returned by MethodL2Keystones. +type L2KeystoneResult struct { + // L2Keystones contains the requested keystones. + L2Keystones []L2Keystone `json:"l2Keystones"` +} + +// L2Keystone represents an L2 keystone. +type L2Keystone struct { + // Version is the version of the L2 keystone. + Version uint8 `json:"version"` + + // L1BlockNumber is the L1 block number for the keystone. + L1BlockNumber uint32 `json:"l1BlockNumber"` + + // L2BlockNumber is the L2 block number for the keystone. + L2BlockNumber uint32 `json:"l2BlockNumber"` + + // EPHash is the hash of the L2 block that contains the PoP payout. + EPHash string `json:"epHash"` + + // ParentEPHash is the parent of the L2 block that contains the PoP payout. + ParentEPHash string `json:"parentEPHash"` + + // PrevKeystoneEPHash is the hash of the L2 block that contains the previous + // keystone PoP payout. + PrevKeystoneEPHash string `json:"prevKeystoneEPHash"` + + // StateRoot is the Ethereum execution payload state root. + StateRoot string `json:"stateRoot"` +} + +// BitcoinBalanceResult contains the balances for the script hash. +// Returned by MethodBitcoinBalance. +type BitcoinBalanceResult struct { + // Confirmed is the confirmed balance in satoshis. + Confirmed uint64 `json:"confirmed"` + + // Unconfirmed is the unconfirmed balance in satoshis. + Unconfirmed int64 `json:"unconfirmed"` +} + +// BitcoinInfoResult contains Bitcoin-related information. +// Returned by MethodBitcoinInfo. +type BitcoinInfoResult struct { + // Height is the current best known Bitcoin block height. + Height uint64 `json:"height"` +} + +// BitcoinUTXOsResult contains the UTXOs for the script hash. +// Returned by MethodBitcoinUTXOs. +type BitcoinUTXOsResult struct { + // UTXOs contains the UTXOs for the script hash. + UTXOs []BitcoinUTXO `json:"utxos"` +} + +// BitcoinUTXO represents a Bitcoin UTXO. +type BitcoinUTXO struct { + // Hash is the output's transaction hash, encoded as a hexadecimal string. + Hash string `json:"hash"` + + // Index is the index of the output in the transaction's list of outputs. + Index uint32 `json:"index"` + + // Value is the value of the output in satoshis. + Value int64 `json:"value"` +} diff --git a/web/popminer/dispatch.go b/web/popminer/dispatch.go index c7a943c2..898f553e 100644 --- a/web/popminer/dispatch.go +++ b/web/popminer/dispatch.go @@ -24,23 +24,8 @@ import ( "github.com/hemilabs/heminetwork/service/popm" ) -const ( - // The following can be dispatched at any time. - MethodVersion = "version" // Retrieve WASM version information - MethodGenerateKey = "generateKey" // Generate secp256k1 key pair - MethodStartPoPMiner = "startPoPMiner" // Start PoP Miner - MethodStopPoPMiner = "stopPoPMiner" // Stop PoP Miner - - // The following can only be dispatched after the PoP Miner is running. - MethodPing = "ping" // Ping BFG - MethodL2Keystones = "l2Keystones" // Retrieve L2 keystones - MethodBitcoinBalance = "bitcoinBalance" // Retrieve bitcoin balance - MethodBitcoinInfo = "bitcoinInfo" // Retrieve bitcoin information - MethodBitcoinUTXOs = "bitcoinUTXOs" // Retrieve bitcoin UTXOs -) - -// Dispatcher maps methods to the relative dispatch. -var Dispatcher = map[string]*Dispatch{ +// handlers maps methods to the correct handler. +var handlers = map[Method]*Dispatch{ MethodVersion: { Handler: wasmVersion, }, @@ -95,7 +80,7 @@ type DispatchArgs struct { } type Dispatch struct { - Handler func(this js.Value, args []js.Value) (js.Value, error) + Handler func(this js.Value, args []js.Value) (any, error) Required []DispatchArgs } @@ -139,7 +124,7 @@ func dispatch(this js.Value, args []js.Value) any { reject.Invoke(jsError(err)) return } - resolve.Invoke(rv) + resolve.Invoke(jsValueOf(rv)) }() // The handler of a Promise doesn't return any value @@ -165,7 +150,7 @@ func parseArgs(args []js.Value) (*Dispatch, error) { if m.Type() != js.TypeString { return nil, fmt.Errorf("expected method to be a string, got: %v", m.Type()) } - d, ok := Dispatcher[m.String()] + d, ok := handlers[Method(m.String())] if !ok { log.Warningf("method not found: %v", m.String()) return nil, fmt.Errorf("method not found: %v", m.String()) @@ -185,14 +170,14 @@ func parseArgs(args []js.Value) (*Dispatch, error) { return d, nil } -func wasmVersion(_ js.Value, _ []js.Value) (js.Value, error) { - return Object{ - "version": version, - "gitCommit": gitCommit, - }.Value(), nil +func wasmVersion(_ js.Value, _ []js.Value) (any, error) { + return VersionResult{ + Version: version, + GitCommit: gitCommit, + }, nil } -func generateKey(_ js.Value, args []js.Value) (js.Value, error) { +func generateKey(_ js.Value, args []js.Value) (any, error) { log.Tracef("generatekey") defer log.Tracef("generatekey exit") @@ -212,7 +197,7 @@ func generateKey(_ js.Value, args []js.Value) (js.Value, error) { return js.Null(), fmt.Errorf("invalid network: %v", net) } - // TODO: consider alternative as dcrsecpk256k1 package is large. + // TODO(joshuasing): consider alternative as dcrsecpk256k1 package is large. privKey, err := dcrsecpk256k1.GeneratePrivateKey() if err != nil { log.Errorf("failed to generate private key: %v", err) @@ -230,23 +215,23 @@ func generateKey(_ js.Value, args []js.Value) (js.Value, error) { compressedPubKey := privKey.PubKey().SerializeCompressed() ethereumAddress := ethereum.PublicKeyToAddress(compressedPubKey).String() - return Object{ - "ethereumAddress": ethereumAddress, - "network": netNormalized, - "privateKey": hex.EncodeToString(privKey.Serialize()), - "publicKey": hex.EncodeToString(compressedPubKey), - "publicKeyHash": btcAddress.AddressPubKeyHash().String(), - }.Value(), nil + return GenerateKeyResult{ + EthereumAddress: ethereumAddress, + Network: netNormalized, + PrivateKey: hex.EncodeToString(privKey.Serialize()), + PublicKey: hex.EncodeToString(compressedPubKey), + PublicKeyHash: btcAddress.AddressPubKeyHash().String(), + }, nil } -func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) { +func startPoPMiner(_ js.Value, args []js.Value) (any, error) { log.Tracef("startPoPMiner") defer log.Tracef("startPoPMiner exit") pmMtx.Lock() defer pmMtx.Unlock() if pm != nil { - return js.Null(), errors.New("pop miner already started") + return nil, errors.New("pop miner already started") } pm = new(Miner) @@ -260,12 +245,16 @@ func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) { if cfg.LogLevel == "" { cfg.LogLevel = "popm=ERROR" } - loggo.ConfigureLoggers(cfg.LogLevel) + if err := loggo.ConfigureLoggers(cfg.LogLevel); err != nil { + pm = nil + return nil, fmt.Errorf("configure logger: %w", err) + } network := args[0].Get("network").String() netOpts, ok := networks[network] if !ok { - return js.Null(), fmt.Errorf("unknown network: %s", network) + pm = nil + return nil, fmt.Errorf("unknown network: %s", network) } cfg.BFGWSURL = netOpts.bfgURL cfg.BTCChainName = netOpts.btcChainName @@ -273,7 +262,8 @@ func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) { var err error pm.miner, err = popm.NewMiner(cfg) if err != nil { - return js.Null(), fmt.Errorf("create PoP miner: %w", err) + pm = nil + return nil, fmt.Errorf("create PoP miner: %w", err) } // launch in background @@ -281,24 +271,24 @@ func startPoPMiner(_ js.Value, args []js.Value) (js.Value, error) { go func() { defer pm.wg.Done() if err := pm.miner.Run(pm.ctx); !errors.Is(err, context.Canceled) { - // TODO: dispatch event on failure + // TODO(joshuasing): dispatch event on failure pmMtx.Lock() defer pmMtx.Unlock() pm.err = err // Theoretically this can logic race unless we unset om } }() - return js.Undefined(), nil + return js.Null(), nil } -func stopPopMiner(_ js.Value, _ []js.Value) (js.Value, error) { +func stopPopMiner(_ js.Value, _ []js.Value) (any, error) { log.Tracef("stopPopMiner") defer log.Tracef("stopPopMiner exit") pmMtx.Lock() if pm == nil { pmMtx.Unlock() - return js.Null(), errors.New("pop miner not running") + return nil, errors.New("pop miner not running") } oldPM := pm @@ -308,13 +298,13 @@ func stopPopMiner(_ js.Value, _ []js.Value) (js.Value, error) { oldPM.wg.Wait() if oldPM.err != nil { - return js.Null(), oldPM.err + return nil, oldPM.err } - return js.Undefined(), nil + return js.Null(), nil } -func ping(_ js.Value, _ []js.Value) (js.Value, error) { +func ping(_ js.Value, _ []js.Value) (any, error) { log.Tracef("ping") defer log.Tracef("ping exit") @@ -327,13 +317,15 @@ func ping(_ js.Value, _ []js.Value) (js.Value, error) { return js.Null(), err } - return Object{ - "originTimestamp": pr.OriginTimestamp, - "timestamp": pr.Timestamp, - }.Value(), nil + // TODO(joshuasing): protocol.PingResponse should really use a more accurate + // time format instead of unix seconds. + return PingResult{ + OriginTimestamp: time.Unix(pr.OriginTimestamp, 0).UnixNano(), + Timestamp: time.Unix(pr.Timestamp, 0).UnixNano(), + }, nil } -func l2Keystones(_ js.Value, args []js.Value) (js.Value, error) { +func l2Keystones(_ js.Value, args []js.Value) (any, error) { log.Tracef("l2Keystones") defer log.Tracef("l2Keystones exit") @@ -352,25 +344,25 @@ func l2Keystones(_ js.Value, args []js.Value) (js.Value, error) { return js.Null(), err } - keystones := make([]Object, len(pr.L2Keystones)) + keystones := make([]L2Keystone, len(pr.L2Keystones)) for i, ks := range pr.L2Keystones { - keystones[i] = Object{ - "version": ks.Version, - "l1BlockNumber": ks.L1BlockNumber, - "l2BlockNumber": ks.L2BlockNumber, - "parentEPHash": ks.ParentEPHash, - "prevKeystoneEPHash": ks.PrevKeystoneEPHash, - "stateRoot": ks.StateRoot, - "epHash": ks.EPHash, + keystones[i] = L2Keystone{ + Version: ks.Version, + L1BlockNumber: ks.L1BlockNumber, + L2BlockNumber: ks.L2BlockNumber, + ParentEPHash: ks.ParentEPHash.String(), + PrevKeystoneEPHash: ks.PrevKeystoneEPHash.String(), + StateRoot: ks.StateRoot.String(), + EPHash: ks.EPHash.String(), } } - return Object{ - "l2Keystones": pr.L2Keystones, - }.Value(), nil + return L2KeystoneResult{ + L2Keystones: keystones, + }, nil } -func bitcoinBalance(_ js.Value, args []js.Value) (js.Value, error) { +func bitcoinBalance(_ js.Value, args []js.Value) (any, error) { log.Tracef("bitcoinBalance") defer log.Tracef("bitcoinBalance exit") @@ -385,13 +377,13 @@ func bitcoinBalance(_ js.Value, args []js.Value) (js.Value, error) { return js.Null(), err } - return Object{ - "confirmed": pr.Confirmed, - "unconfirmed": pr.Unconfirmed, - }.Value(), nil + return BitcoinBalanceResult{ + Confirmed: pr.Confirmed, + Unconfirmed: pr.Unconfirmed, + }, nil } -func bitcoinInfo(_ js.Value, _ []js.Value) (js.Value, error) { +func bitcoinInfo(_ js.Value, _ []js.Value) (any, error) { log.Tracef("bitcoinInfo") defer log.Tracef("bitcoinInfo exit") @@ -404,12 +396,12 @@ func bitcoinInfo(_ js.Value, _ []js.Value) (js.Value, error) { return js.Null(), err } - return Object{ - "height": pr.Height, - }.Value(), nil + return BitcoinInfoResult{ + Height: pr.Height, + }, nil } -func bitcoinUTXOs(this js.Value, args []js.Value) (js.Value, error) { +func bitcoinUTXOs(this js.Value, args []js.Value) (any, error) { log.Tracef("bitcoinUTXOs") defer log.Tracef("bitcoinUTXOs exit") @@ -417,23 +409,23 @@ func bitcoinUTXOs(this js.Value, args []js.Value) (js.Value, error) { activePM, err := activeMiner() if err != nil { - return js.Null(), err + return nil, err } pr, err := activePM.miner.BitcoinUTXOs(activePM.ctx, scriptHash) if err != nil { - return js.Null(), err + return nil, err } - utxos := make([]Object, len(pr.UTXOs)) + utxos := make([]BitcoinUTXO, len(pr.UTXOs)) for i, u := range pr.UTXOs { - utxos[i] = Object{ - "hash": u.Hash, - "index": u.Index, - "value": u.Value, + utxos[i] = BitcoinUTXO{ + Hash: u.Hash.String(), + Index: u.Index, + Value: u.Value, } } - return Object{ - "utxos": utxos, - }.Value(), nil + return BitcoinUTXOsResult{ + UTXOs: utxos, + }, nil } diff --git a/web/popminer/util.go b/web/popminer/util.go index 6b10ead5..3e7b9be2 100644 --- a/web/popminer/util.go +++ b/web/popminer/util.go @@ -7,11 +7,11 @@ package main import ( + "reflect" "runtime/debug" + "strings" "syscall/js" "time" - - "github.com/hemilabs/heminetwork/api" ) var ( @@ -20,31 +20,157 @@ var ( arrayConstructor = js.Global().Get("Array") ) -// Object represents a JavaScript Object. -type Object map[string]any - -// Value returns a [js.Value] containing a JavaScript Object for the object. -func (o Object) Value() js.Value { - obj := objectConstructor.New() - for k, v := range o { - switch t := v.(type) { - case Object: - obj.Set(k, t.Value()) - case []Object: - a := arrayConstructor.New(len(t)) - for i, so := range t { - a.SetIndex(i, so.Value()) +// jsValueOf returns x as a JavaScript value. +// +// | Go | JavaScript | +// | ---------------------- | ---------------------- | +// | nil | null | +// | js.Value | [value] | +// | js.Func | function | +// | bool | boolean | +// | integers and floats | number | +// | string | string | +// | []any and [x]any | Array | +// | map[string]any | Object | +// | struct | Object | +// | all others | undefined | +func jsValueOf(x any) js.Value { + switch t := x.(type) { + case nil: + return js.Null() + case js.Value: + return t + case js.Func: + return t.Value + case bool, + int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, uintptr, + float32, float64, string: + return js.ValueOf(t) + case []any: + a := arrayConstructor.New(len(t)) + for i, s := range t { + a.SetIndex(i, s) + } + return a + case map[string]any: + o := objectConstructor.New() + for k, v := range t { + o.Set(k, v) + } + return o + default: + // Attempt reflection, will fall back to using jsValueSafe. + return jsReflectValueOf(reflect.ValueOf(x)) + } +} + +func jsReflectValueOf(rv reflect.Value) js.Value { + switch rv.Kind() { + case reflect.Ptr, reflect.Interface: + if rv.IsNil() { + return js.Null() + } + return jsReflectValueOf(rv.Elem()) + case reflect.Slice, reflect.Array: + if rv.IsNil() { + return js.Null() + } + a := arrayConstructor.New(rv.Len()) + for i := range rv.Len() { + a.SetIndex(i, jsReflectValueOf(rv.Index(i))) + } + return a + case reflect.Map: + if rv.IsNil() { + return js.Null() + } + o := objectConstructor.New() + i := rv.MapRange() + for i.Next() { + k, ok := i.Key().Interface().(string) + if !ok { + // Non-string keys are unsupported. + log.Warningf("cannot encode map with non-string key %v", + i.Key().Type()) + return js.Undefined() } - obj.Set(k, a) - case api.ByteSlice: - // Special handling for api.ByteSlice. - // calls String() to return value as a hexadecimal encoded string - obj.Set(k, t.String()) - default: - obj.Set(k, jsValueSafe(v)) + o.Set(k, jsReflectValueOf(i.Value())) + } + return o + case reflect.Struct: + return jsReflectStruct(rv) + default: + if !rv.CanInterface() { + log.Warningf("cannot encode reflect value of type %v", rv.Type()) + return js.Undefined() + } + return jsValueSafe(rv.Interface()) + } +} + +// jsReflectStruct converts a Go struct to a JavaScript Object, using the 'json' +// struct field tags similar to the encoding/json package. +// +// Note: This may not handle anonymous or embedded structs and other uncommon +// types inside structs, additionally the 'string' json tag option is not +// supported. +func jsReflectStruct(rv reflect.Value) js.Value { + o := objectConstructor.New() + t := rv.Type() + for i := range t.NumField() { + f := rv.Field(i) + if !f.CanInterface() { + continue } + sf := t.Field(i) + + tag := sf.Tag.Get("json") + if tag == "-" { + continue + } + name, opts := parseJSONTag(tag) + if name == "" { + name = sf.Name + } + + if opts.Contains("omitempty") && isEmptyValue(f) { + continue + } + o.Set(name, jsReflectValueOf(f)) + } + return o +} + +type jsonOptions []string + +func (o jsonOptions) Contains(optionName string) bool { + for _, option := range o { + if option == optionName { + return true + } + } + return false +} + +func parseJSONTag(tag string) (string, jsonOptions) { + tag, opt, _ := strings.Cut(tag, ",") + return tag, strings.Split(opt, ",") +} + +func isEmptyValue(rv reflect.Value) bool { + switch rv.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return rv.Len() == 0 + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, + reflect.Interface, reflect.Pointer: + return rv.IsZero() + default: + return false } - return obj } // jsValueSafe wraps js.ValueOf and recovers when js.ValueOf panics due to it @@ -66,9 +192,9 @@ func jsError(err error) js.Value { defer log.Tracef("jsError exit") stack := string(debug.Stack()) - return Object{ - "message": err.Error(), - "stack": stack, - "timestamp": time.Now().Unix(), - }.Value() + return jsValueOf(Error{ + Message: err.Error(), + Stack: stack, + Timestamp: time.Now().Unix(), + }) } diff --git a/web/www/index.html b/web/www/index.html index dc6a89d9..bf4c9889 100644 --- a/web/www/index.html +++ b/web/www/index.html @@ -37,7 +37,7 @@
- +
@@ -64,7 +64,7 @@

- +