From a10c9bc7ed119bd7dbf81c9cfcc1392409d1fae3 Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Fri, 7 Jun 2024 06:12:56 +1000 Subject: [PATCH 1/7] popm/wasm: improve and tidy up Go code This splits the web/popminer/popminer.go file into 5 files: - dispatch.go: Contains all methods that can be dispatched from JS - network.go: Contains network settings - network_local.go: Contains localnet settings (behind build tag) - popminer.go: Contains the main function and core - util.go: Contains utilities for working with JS This contains a lot of miscellaneous improvements, however the notable ones are: - Invoke Promise resolve function with js.Value instead of marshaled JSON, meaning that the result can be used directly without the need to encode/decode JSON. - All errors use a common format containing the error message, a timestamp, and the debug stack, allowing for easier debugging. - Replace 'wasmPing' with 'version' - wasmPing is not very useful and was only used for testing the connection between JS and WASM. version returns useful version information and allows the same testing. - Add "Object" type that can be used to easily create JS Objects from Go. Calling Object.Value() will create a js.Value representing the object, converting each contained value to a js.Value. This also has the benefit of breaking the build if a key changes, preventing breaks in the JS API. - Move network configurations into a separate file and use constants. I think this is cleaner than the previous solution, and allows localnet to only be available when built with the 'hemi_localnet' build tag. --- cmd/popmd/popmd.go | 2 +- web/Makefile | 28 +- web/popminer/dispatch.go | 438 ++++++++++++++++++++++++++ web/popminer/network.go | 38 +++ web/popminer/network_local.go | 19 ++ web/popminer/popminer.go | 496 ++---------------------------- web/popminer/util.go | 74 +++++ web/www/index.html | 28 +- web/www/index.js | 99 +++--- web/www/popminer.js | 4 +- web/www/wasm_exec.js | 563 ++++++++++++++++++++++++++++++++++ 11 files changed, 1250 insertions(+), 539 deletions(-) create mode 100644 web/popminer/dispatch.go create mode 100644 web/popminer/network.go create mode 100644 web/popminer/network_local.go create mode 100644 web/popminer/util.go create mode 100644 web/www/wasm_exec.js diff --git a/cmd/popmd/popmd.go b/cmd/popmd/popmd.go index ab8ca815..a532cee5 100644 --- a/cmd/popmd/popmd.go +++ b/cmd/popmd/popmd.go @@ -52,7 +52,7 @@ var ( "POPM_BTC_CHAIN_NAME": config.Config{ Value: &cfg.BTCChainName, DefaultValue: popm.NewDefaultConfig().BTCChainName, - Help: "the name of the bitcoing chain to connect to (ex. \"mainnet\", \"testnet3\")", + Help: "the name of the bitcoin chain to connect to (ex. \"mainnet\", \"testnet3\")", Print: config.PrintAll, }, "POPM_PROMETHEUS_ADDRESS": config.Config{ diff --git a/web/Makefile b/web/Makefile index bf700bc9..8586d49a 100644 --- a/web/Makefile +++ b/web/Makefile @@ -2,26 +2,32 @@ # Use of this source code is governed by the MIT License, # which can be found in the LICENSE file. +PROJECTPATH = $(abspath $(dir $(realpath $(firstword $(MAKEFILE_LIST))))) + GOROOT=$(shell go env GOROOT) -GITVERSION=$(shell git rev-parse --short HEAD) -WEBAPP=webapp +WEBAPP=$(PROJECTPATH)/webapp +WASM_BINARY=$(WEBAPP)/popminer.wasm + +version = $(patsubst v%,%,$(shell git describe --tags 2>/dev/null || echo "v0.0.0")) +commit = $(shell git rev-parse --short HEAD) -.PHONY: all clean prepare wasm www +.PHONY: all clean wasm www all: wasm www clean: rm -rf ${WEBAPP} + rm -rf ${WASM_BINARY} -prepare: - mkdir -p ${WEBAPP} +# TODO(joshuasing): research using binaryen (wasm-opt) to optimise output binary +wasm: + CGO_ENABLED=0 GOOS=js GOARCH=wasm go build -trimpath -tags "$(BUILD_TAGS)" \ + -ldflags "-s -w -X main.version=${version} -X main.gitCommit=${commit}" \ + -o ${WASM_BINARY} ./popminer/... -wasm: prepare - GOOS=js GOARCH=wasm go build -trimpath -ldflags "-X main.gitVersion=${GITVERSION}" \ - -o ${WEBAPP}/popminer.wasm ./popminer/popminer.go - -www: prepare +www: wasm + mkdir -p ${WEBAPP} cp www/index.html ${WEBAPP} cp www/index.js ${WEBAPP} cp www/popminer.js ${WEBAPP} - cp ${GOROOT}/misc/wasm/wasm_exec.js ${WEBAPP}/wasm_exec.js + cp www/wasm_exec.js ${WEBAPP} diff --git a/web/popminer/dispatch.go b/web/popminer/dispatch.go new file mode 100644 index 00000000..a64c5817 --- /dev/null +++ b/web/popminer/dispatch.go @@ -0,0 +1,438 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + +package main + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "runtime/debug" + "syscall/js" + "time" + + "github.com/btcsuite/btcd/btcutil" + btcchaincfg "github.com/btcsuite/btcd/chaincfg" + dcrsecpk256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/ethereum" + "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{ + MethodVersion: { + Handler: wasmVersion, + }, + MethodGenerateKey: { + Handler: generateKey, + Required: []DispatchArgs{ + {Name: "network", Type: js.TypeString}, + }, + }, + MethodStartPoPMiner: { + Handler: startPoPMiner, + Required: []DispatchArgs{ + {Name: "logLevel", Type: js.TypeString}, + {Name: "network", Type: js.TypeString}, + {Name: "privateKey", Type: js.TypeString}, + }, + }, + MethodStopPoPMiner: { + Handler: stopPopMiner, + }, + + // The following can only be dispatched after the PoP Miner is running. + MethodPing: { + Handler: ping, + }, + MethodL2Keystones: { + Handler: l2Keystones, + Required: []DispatchArgs{ + {Name: "numL2Keystones", Type: js.TypeNumber}, + }, + }, + MethodBitcoinBalance: { + Handler: bitcoinBalance, + Required: []DispatchArgs{ + {Name: "scriptHash", Type: js.TypeString}, + }, + }, + MethodBitcoinInfo: { + Handler: bitcoinInfo, + }, + MethodBitcoinUTXOs: { + Handler: bitcoinUTXOs, + Required: []DispatchArgs{ + {Name: "scriptHash", Type: js.TypeString}, + }, + }, +} + +type DispatchArgs struct { + Name string + Type js.Type +} + +type Dispatch struct { + Handler func(this js.Value, args []js.Value) (js.Value, error) + Required []DispatchArgs +} + +func dispatch(this js.Value, args []js.Value) any { + defer func() { + if r := recover(); r != nil { + log.Criticalf("recovered panic: %v", r) + log.Criticalf(string(debug.Stack())) + } + }() + + log.Tracef("dispatch") + defer log.Tracef("dispatch exit") + + // Create JS Promise handler + handler := js.FuncOf(func(_ js.Value, handlerArgs []js.Value) any { + resolve := handlerArgs[0] + reject := handlerArgs[1] + + // Run dispatched call asynchronously + go func() { + // This function must always complete a promise. + defer func() { + if r := recover(); r != nil { + log.Criticalf("recovered panic: %v", r) + log.Criticalf(string(debug.Stack())) + reject.Invoke(jsError(fmt.Errorf("recovered panic: %v", r))) + } + }() + + // parse args + d, err := parseArgs(args) + if err != nil { + reject.Invoke(jsError(err)) + return + } + + // dispatch sanitized call + rv, err := d.Handler(this, args) + if err != nil { + reject.Invoke(jsError(err)) + return + } + resolve.Invoke(rv) + }() + + // The handler of a Promise doesn't return any value + return nil + }) + defer handler.Release() + + // Create and return the Promise object + return promiseConstructor.New(handler) +} + +func parseArgs(args []js.Value) (*Dispatch, error) { + // Verify we received data readable command + if len(args) != 1 { + return nil, fmt.Errorf("expected 1 argument, got %v", len(args)) + } + data := args[0] + if data.Type() != js.TypeObject { + return nil, fmt.Errorf("expected an object, got: %v", data.Type()) + } + + m := data.Get("method") + if m.Type() != js.TypeString { + return nil, fmt.Errorf("expected method to be a string, got: %v", m.Type()) + } + d, ok := Dispatcher[m.String()] + if !ok { + log.Warningf("method not found: %v", m.String()) + return nil, fmt.Errorf("method not found: %v", m.String()) + } + + // Verify required args + for k := range d.Required { + name := d.Required[k].Name + typ := d.Required[k].Type + arg := data.Get(name) + if arg.Type() != typ { + return nil, fmt.Errorf("%v: invalid type %v, want %v", + name, arg.Type(), typ) + } + } + + return d, nil +} + +func wasmVersion(_ js.Value, _ []js.Value) (js.Value, error) { + return Object{ + "version": version, + "gitCommit": gitCommit, + }.Value(), nil +} + +func generateKey(_ js.Value, args []js.Value) (js.Value, error) { + log.Tracef("generatekey") + defer log.Tracef("generatekey exit") + + var ( + btcChainParams *btcchaincfg.Params + netNormalized string + ) + net := args[0].Get("network").String() + switch net { + case "devnet", "testnet3", "testnet": + btcChainParams = &btcchaincfg.TestNet3Params + netNormalized = "testnet3" + case "mainnet": + btcChainParams = &btcchaincfg.MainNetParams + netNormalized = "mainnet" + default: + return js.Null(), fmt.Errorf("invalid network: %v", net) + } + + privKey, err := dcrsecpk256k1.GeneratePrivateKey() + if err != nil { + log.Errorf("failed to generate private key: %v", err) + return js.Null(), fmt.Errorf("generate secp256k1 private key: %w", err) + } + btcAddress, err := btcutil.NewAddressPubKey( + privKey.PubKey().SerializeCompressed(), + btcChainParams, + ) + if err != nil { + log.Errorf("failed to generate btc address: %v", err) + return js.Null(), fmt.Errorf("create BTC address from public key: %v", err) + } + + 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 +} + +func startPoPMiner(_ js.Value, args []js.Value) (js.Value, 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") + } + + pm = new(Miner) + pm.ctx, pm.cancel = context.WithCancel(context.Background()) + + cfg := popm.NewDefaultConfig() + cfg.BTCPrivateKey = args[0].Get("privateKey").String() + cfg.StaticFee = uint(args[0].Get("staticFee").Int()) + + cfg.LogLevel = args[0].Get("logLevel").String() + if cfg.LogLevel == "" { + cfg.LogLevel = "popm=ERROR" + } + loggo.ConfigureLoggers(cfg.LogLevel) + + network := args[0].Get("network").String() + netOpts, ok := networks[network] + if !ok { + return js.Null(), fmt.Errorf("unknown network: %s", network) + } + cfg.BFGWSURL = netOpts.bfgURL + cfg.BTCChainName = netOpts.btcChainName + + var err error + pm.miner, err = popm.NewMiner(cfg) + if err != nil { + return js.Null(), fmt.Errorf("create PoP miner: %w", err) + } + + // launch in background + pm.wg.Add(1) + go func() { + defer pm.wg.Done() + if err := pm.miner.Run(pm.ctx); !errors.Is(err, context.Canceled) { + // TODO: 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 +} + +func stopPopMiner(_ js.Value, _ []js.Value) (js.Value, 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") + } + + oldPM := pm + pm = nil + pmMtx.Unlock() + oldPM.cancel() + oldPM.wg.Wait() + + if oldPM.err != nil { + return js.Null(), oldPM.err + } + + return js.Undefined(), nil +} + +func ping(_ js.Value, _ []js.Value) (js.Value, error) { + log.Tracef("ping") + defer log.Tracef("ping exit") + + activePM, err := activeMiner() + if err != nil { + return js.Null(), err + } + pr, err := activePM.miner.Ping(activePM.ctx, time.Now().Unix()) + if err != nil { + return js.Null(), err + } + + return Object{ + "originTimestamp": pr.OriginTimestamp, + "timestamp": pr.Timestamp, + }.Value(), nil +} + +func l2Keystones(_ js.Value, args []js.Value) (js.Value, error) { + log.Tracef("l2Keystones") + defer log.Tracef("l2Keystones exit") + + c := args[0].Get("numL2Keystones").Int() + if c < 0 || c > 10 { + c = 2 + } + count := uint64(c) + + activePM, err := activeMiner() + if err != nil { + return js.Null(), err + } + pr, err := activePM.miner.L2Keystones(activePM.ctx, count) + if err != nil { + return js.Null(), err + } + + keystones := make([]Object, 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, + } + } + + return Object{ + "l2Keystones": pr.L2Keystones, + }.Value(), nil +} + +func bitcoinBalance(_ js.Value, args []js.Value) (js.Value, error) { + log.Tracef("bitcoinBalance") + defer log.Tracef("bitcoinBalance exit") + + scriptHash := args[0].Get("scriptHash").String() + + activePM, err := activeMiner() + if err != nil { + return js.Null(), err + } + pr, err := activePM.miner.BitcoinBalance(activePM.ctx, scriptHash) + if err != nil { + return js.Null(), err + } + + return Object{ + "confirmed": pr.Confirmed, + "unconfirmed": pr.Unconfirmed, + }.Value(), nil +} + +func bitcoinInfo(_ js.Value, _ []js.Value) (js.Value, error) { + log.Tracef("bitcoinInfo") + defer log.Tracef("bitcoinInfo exit") + + activePM, err := activeMiner() + if err != nil { + return js.Null(), err + } + pr, err := activePM.miner.BitcoinInfo(activePM.ctx) + if err != nil { + return js.Null(), err + } + + return Object{ + "height": pr.Height, + }.Value(), nil +} + +func bitcoinUTXOs(this js.Value, args []js.Value) (js.Value, error) { + log.Tracef("bitcoinUTXOs") + defer log.Tracef("bitcoinUTXOs exit") + + scriptHash := args[0].Get("scriptHash").String() + + activePM, err := activeMiner() + if err != nil { + return js.Null(), err + } + pr, err := activePM.miner.BitcoinUTXOs(activePM.ctx, scriptHash) + if err != nil { + return js.Null(), err + } + + utxos := make([]Object, len(pr.UTXOs)) + for i, u := range pr.UTXOs { + utxos[i] = Object{ + "hash": u.Hash, + "index": u.Index, + "value": u.Value, + } + } + + return Object{ + "utxos": utxos, + }.Value(), nil +} diff --git a/web/popminer/network.go b/web/popminer/network.go new file mode 100644 index 00000000..71dbddd7 --- /dev/null +++ b/web/popminer/network.go @@ -0,0 +1,38 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + +package main + +const ( + btcChainTestnet3 = "testnet3" + btcChainMainnet = "mainnet" + + rpcHemiTestnet = "wss://testnet.rpc.hemi.network" + rpcHemiDevnet = "wss://devnet.rpc.hemi.network" + rpcHemiMainnet = "wss://rpc.hemi.network" + + bfgRoute = "/v1/ws/public" +) + +type networkOptions struct { + bfgURL string + btcChainName string +} + +var networks = map[string]networkOptions{ + "testnet": { + bfgURL: rpcHemiTestnet + bfgRoute, + btcChainName: btcChainTestnet3, + }, + "devnet": { + bfgURL: rpcHemiDevnet + bfgRoute, + btcChainName: btcChainTestnet3, + }, + "mainnet": { + bfgURL: rpcHemiMainnet + bfgRoute, + btcChainName: btcChainMainnet, + }, +} diff --git a/web/popminer/network_local.go b/web/popminer/network_local.go new file mode 100644 index 00000000..7540a5f4 --- /dev/null +++ b/web/popminer/network_local.go @@ -0,0 +1,19 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm && hemi_localnet + +package main + +// To include the "local" network option in the WASM binary, run `go build` +// with `-tags "hemi_localnet"` to include this file. + +const rpcLocal = "ws://localhost:8383" + +func init() { + networks["local"] = networkOptions{ + bfgURL: rpcLocal + bfgRoute, + btcChainName: btcChainTestnet3, + } +} diff --git a/web/popminer/popminer.go b/web/popminer/popminer.go index 0ec6d00b..26c6b02e 100644 --- a/web/popminer/popminer.go +++ b/web/popminer/popminer.go @@ -8,34 +8,34 @@ package main import ( "context" - "encoding/hex" - "encoding/json" "errors" - "fmt" "os" "path/filepath" "runtime" - "runtime/debug" "sync" "syscall/js" - "time" - "github.com/btcsuite/btcd/btcutil" - btcchaincfg "github.com/btcsuite/btcd/chaincfg" - dcrsecpk256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/juju/loggo" - "github.com/hemilabs/heminetwork/ethereum" "github.com/hemilabs/heminetwork/service/popm" ) -const ( - logLevel = "INFO" - version = "1.0.0" +var ( + // version and gitCommit are set via ldflags at link-time. + version = "" + gitCommit = "" + + logLevel = "ERROR" // Can be set with `-ldflags "-X main.logLevel=TRACE"` + log = loggo.GetLogger("@hemilabs/pop-miner") +) + +var ( + pmMtx sync.Mutex + pm *Miner // Global Miner instance. ) -// This is used globally -type PopMiner struct { +// Miner is a global instance of [popm.Miner]. +type Miner struct { // Don't like adding these into the object but c'est la wasm ctx context.Context cancel context.CancelFunc @@ -45,470 +45,34 @@ type PopMiner struct { err error } -type DispatchArgs struct { - Name string - Type js.Type -} - -type Dispatch struct { - Call func(js.Value, []js.Value) (any, error) - Required []DispatchArgs -} - -func wasmPing(this js.Value, args []js.Value) (any, error) { - log.Tracef("wasmPing") - defer log.Tracef("wasmPing exit") - - message := args[0].Get("message").String() - message += " response" - - return map[string]any{"response": message}, nil -} - -func generateKey(this js.Value, args []js.Value) (any, error) { - log.Tracef("generatekey") - defer log.Tracef("generatekey exit") - - net := args[0].Get("network").String() - var ( - btcChainParams *btcchaincfg.Params - netNormalized string - ) - switch net { - case "devnet", "testnet3", "testnet": - btcChainParams = &btcchaincfg.TestNet3Params - netNormalized = "testnet3" - case "mainnet": - btcChainParams = &btcchaincfg.MainNetParams - netNormalized = "mainnet" - default: - return nil, fmt.Errorf("invalid network: %v", net) - } - privKey, err := dcrsecpk256k1.GeneratePrivateKey() - if err != nil { - return nil, fmt.Errorf("generate secp256k1 private key: %w", err) - } - btcAddress, err := btcutil.NewAddressPubKey(privKey.PubKey().SerializeCompressed(), - btcChainParams) - if err != nil { - return nil, fmt.Errorf("create BTC address from public key: %v", - err) - } - hash := btcAddress.AddressPubKeyHash().String() - ethAddress := ethereum.AddressFromPrivateKey(privKey) - - return map[string]any{ - "ethereumAddress": ethAddress.String(), - "network": netNormalized, - "privateKey": hex.EncodeToString(privKey.Serialize()), - "publicKey": hex.EncodeToString(privKey.PubKey().SerializeCompressed()), - "publicKeyHash": hash, - }, nil -} - -func runPopMiner(this js.Value, args []js.Value) (any, error) { - log.Tracef("runPopMiner") - defer log.Tracef("runPopMiner exit") - - globalMtx.Lock() - if pm != nil { - globalMtx.Unlock() - return map[string]any{"error": "pop miner already running"}, nil - } - - // Don't love doing this in mutex but other options are also costly - pm = &PopMiner{} - pm.ctx, pm.cancel = context.WithCancel(context.Background()) - cfg := popm.NewDefaultConfig() - cfg.BTCChainName = args[0].Get("network").String() - cfg.BTCPrivateKey = args[0].Get("privateKey").String() - cfg.LogLevel = args[0].Get("logLevel").String() // "popm=TRACE:protocol=TRACE" - cfg.StaticFee = uint(args[0].Get("staticFee").Int()) - if cfg.LogLevel == "" { - cfg.LogLevel = "popm=INFO" - } - loggo.ConfigureLoggers(cfg.LogLevel) - - switch cfg.BTCChainName { - case "testnet", "testnet3": - cfg.BFGWSURL = "wss://testnet.rpc.hemi.network" - cfg.BTCChainName = "testnet3" - case "devnet": - cfg.BFGWSURL = "wss://devnet.rpc.hemi.network" - cfg.BTCChainName = "testnet3" - case "local": - // XXX this should only be enabled with a link flag - cfg.BFGWSURL = "ws://localhost:8383" - cfg.BTCChainName = "testnet3" - case "mainnet": - cfg.BFGWSURL = "wss://rpc.hemi.network" - default: - return map[string]any{"error": "invalid network for pop miner"}, nil - } - // We hardcode the route here because we do not want to include another - // packge thus growing WASM. - bfgRoute := "/v1/ws/public" - cfg.BFGWSURL += bfgRoute - - var err error - pm.miner, err = popm.NewMiner(cfg) - if err != nil { - globalMtx.Unlock() - return nil, fmt.Errorf("create POP miner: %w", err) - } - globalMtx.Unlock() - - // launch in background - pm.wg.Add(1) - go func() { - defer pm.wg.Done() - if err := pm.miner.Run(pm.ctx); !errors.Is(err, context.Canceled) { - globalMtx.Lock() - defer globalMtx.Unlock() - pm.err = err // Theoretically this can logic race unless we unset om - } - }() - - return map[string]any{"error": ""}, nil -} - -func stopPopMiner(this js.Value, args []js.Value) (any, error) { - log.Tracef("stopPopMiner") - defer log.Tracef("stopPopMiner exit") - - globalMtx.Lock() - if pm == nil { - globalMtx.Unlock() - return map[string]any{"error": "pop miner not running"}, nil - } - oldPM := pm - pm = nil - globalMtx.Unlock() - oldPM.cancel() - - oldPM.wg.Wait() - - var exitError string - if oldPM.err != nil { - exitError = oldPM.err.Error() - } - - return map[string]any{ - "error": exitError, - }, nil -} - -func activePopMiner() (*PopMiner, error) { - globalMtx.Lock() - defer globalMtx.Unlock() - if pm == nil { - return nil, errors.New("pop miner not running") - } - return pm, nil -} - -// toMap converts a bfg response to a map. Errors are also encoded in a map. -func toMap(response any) map[string]any { - jr, err := json.Marshal(response) - if err != nil { - return map[string]any{"error": err.Error()} - } - mr := make(map[string]any, 10) - err = json.Unmarshal(jr, &mr) - if err != nil { - return map[string]any{"error": err.Error()} - } - return mr -} - -func ping(this js.Value, args []js.Value) (any, error) { - log.Tracef("ping") - defer log.Tracef("ping exit") - - activePM, err := activePopMiner() - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - pr, err := activePM.miner.Ping(activePM.ctx, time.Now().Unix()) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - - return toMap(pr), nil -} - -func l2Keystones(this js.Value, args []js.Value) (any, error) { - log.Tracef("l2Keystones") - defer log.Tracef("l2Keystones exit") - - c := args[0].Get("numL2Keystones").Int() - if c < 0 || c > 10 { - c = 2 - } - count := uint64(c) - - activePM, err := activePopMiner() - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - pr, err := activePM.miner.L2Keystones(activePM.ctx, count) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - - return toMap(pr), nil -} - -func bitcoinBalance(this js.Value, args []js.Value) (any, error) { - log.Tracef("bitcoinBalance") - defer log.Tracef("bitcoinBalance exit") - - scriptHash := args[0].Get("scriptHash").String() - - activePM, err := activePopMiner() - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - pr, err := activePM.miner.BitcoinBalance(activePM.ctx, scriptHash) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - - return toMap(pr), nil -} - -func bitcoinInfo(this js.Value, args []js.Value) (any, error) { - log.Tracef("bitcoinInfo") - defer log.Tracef("bitcoinInfo exit") - - activePM, err := activePopMiner() - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - pr, err := activePM.miner.BitcoinInfo(activePM.ctx) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - - return toMap(pr), nil -} - -func bitcoinUTXOs(this js.Value, args []js.Value) (any, error) { - log.Tracef("bitcoinUTXOs") - defer log.Tracef("bitcoinUTXOs exit") - - scriptHash := args[0].Get("scriptHash").String() - - activePM, err := activePopMiner() - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - pr, err := activePM.miner.BitcoinUTXOs(activePM.ctx, scriptHash) - if err != nil { - return map[string]any{"error": err.Error()}, nil - } - - return toMap(pr), nil -} - -var ( - log = loggo.GetLogger("hemiwasm") - gitVersion = "not set yet" - - // pre connection and control - CWASMPing = "wasmping" // WASM ping - CGenerateKey = "generatekey" // Generate the various key - CRunPopMiner = "runpopminer" // Run pop miner - CStopPopMiner = "stoppopminer" // Stop pop miner - - // post connection - CPing = "ping" // ping - CL2Keystones = "l2Keystones" // Get L2 keystones - CBitcoinBalance = "bitcoinBalance" // get balance - CBitcoinInfo = "bitcoinInfo" // bitcoin information - CBitcoinUTXOs = "bitcoinUtxos" // bitcoin UTXOs - Dispatcher = map[string]Dispatch{ - CWASMPing: { - Call: wasmPing, - Required: []DispatchArgs{ - {Name: "message", Type: js.TypeString}, - }, - }, - CGenerateKey: { - Call: generateKey, - Required: []DispatchArgs{ - {Name: "network", Type: js.TypeString}, - }, - }, - CRunPopMiner: { - Call: runPopMiner, - Required: []DispatchArgs{ - {Name: "logLevel", Type: js.TypeString}, - {Name: "network", Type: js.TypeString}, - {Name: "privateKey", Type: js.TypeString}, - }, - }, - CStopPopMiner: { - Call: stopPopMiner, - Required: []DispatchArgs{{}}, - }, - - // post connection - CPing: { - Call: ping, - Required: []DispatchArgs{ - {Name: "timestamp", Type: js.TypeNumber}, - }, - }, - CL2Keystones: { - Call: l2Keystones, - Required: []DispatchArgs{ - {Name: "numL2Keystones", Type: js.TypeNumber}, - }, - }, - CBitcoinBalance: { - Call: bitcoinBalance, - Required: []DispatchArgs{ - {Name: "scriptHash", Type: js.TypeString}, - }, - }, - CBitcoinInfo: { - Call: bitcoinInfo, - Required: []DispatchArgs{}, - }, - CBitcoinUTXOs: { - Call: bitcoinUTXOs, - Required: []DispatchArgs{ - {Name: "scriptHash", Type: js.TypeString}, - }, - }, - } - - globalMtx sync.Mutex // used to set and unset pm - pm *PopMiner -) - func init() { loggo.ConfigureLoggers(logLevel) } -func validateArgs(args []js.Value) (Dispatch, error) { - // Verify we received a readable command - var ed Dispatch - if len(args) != 1 { - return ed, fmt.Errorf("1 argument expected, got %v", len(args)) - } - a := args[0] - if a.Type() != js.TypeObject { - return ed, fmt.Errorf("expected an object, got: %v", a.Type()) - } - m := a.Get("method") - if m.Type() != js.TypeString { - return ed, fmt.Errorf("expected a string, got: %v", m.Type()) - } - d, ok := Dispatcher[m.String()] - if !ok { - return ed, fmt.Errorf("method not found: %v", m.String()) - } - - // Verify required args - for k := range d.Required { - name := d.Required[k].Name - typ := d.Required[k].Type - arg := a.Get(name) - if arg.Type() != typ { - return d, fmt.Errorf("invalid type %v: got %v want %v", - name, arg.Type(), typ) - } - } - return d, nil -} - -func execute(this js.Value, args []js.Value) any { - log.Tracef("execute") - defer log.Tracef("execute exit") - - // Setup promise - handler := js.FuncOf(func(this js.Value, handlerArgs []js.Value) any { - resolve := handlerArgs[0] - reject := handlerArgs[1] - - // Run dispatched call asynchronously - go func() { - // This function must always complete a promise. - var err error - defer func() { - if r := recover(); r != nil { - p := fmt.Sprintf("recovered panic: %v\n%v", - r, string(debug.Stack())) - log.Criticalf(p) - reject.Invoke(js.Global().Get("Error").New(p)) - } else if err != nil { - reject.Invoke(js.Global().Get("Error").New(err.Error())) - } - }() - - // verify args - var d Dispatch - d, err = validateArgs(args) - if err != nil { - return - } - - // dispatch sanitized call - var rv any - rv, err = d.Call(this, args) - if err != nil { - return - } - - // encode response - var j []byte - j, err = json.Marshal(rv) - if err != nil { - return - } - resolve.Invoke(string(j)) - }() - - // The handler of a Promise doesn't return any value - return nil - }) - // Create and return the Promise object - return js.Global().Get("Promise").New(handler) -} - -func dispatch(this js.Value, args []js.Value) any { - defer func() { - if r := recover(); r != nil { - log.Criticalf("recovered panic: %v", r) - log.Criticalf("%v", string(debug.Stack())) - } - }() - - log.Tracef("dispatch") - defer log.Tracef("dispatch exit") - - rv := execute(this, args) - if err, ok := rv.(error); ok && err != nil { - return js.Global().Get("Error").New(err.Error()) - } - return rv -} - func main() { log.Tracef("main") defer log.Tracef("main exit") // Enable function dispatcher log.Infof("=== Start of Day ===") - // Don't use monitorclient.Runtime here because gitVersion is linked in. log.Infof("%v version %v compiled with go version %v %v/%v revision %v", filepath.Base(os.Args[0]), version, runtime.Version(), - runtime.GOOS, runtime.GOARCH, gitVersion) - log.Infof("Logging level : %v", logLevel) + runtime.GOOS, runtime.GOARCH, gitCommit) + log.Infof("Logging level: %v", logLevel) + + // Set global variable + module := objectConstructor.New() + module.Set("dispatch", js.FuncOf(dispatch)) + js.Global().Set("@hemilabs/pop-miner", module) - js.Global().Set("dispatch", js.FuncOf(dispatch)) + <-make(chan struct{}) // prevents the program from exiting +} - <-make(chan bool) // prevents the program from exiting +func activeMiner() (*Miner, error) { + pmMtx.Lock() + defer pmMtx.Unlock() + if pm == nil { + return nil, errors.New("pop miner not running") + } + return pm, nil } diff --git a/web/popminer/util.go b/web/popminer/util.go new file mode 100644 index 00000000..6b10ead5 --- /dev/null +++ b/web/popminer/util.go @@ -0,0 +1,74 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + +package main + +import ( + "runtime/debug" + "syscall/js" + "time" + + "github.com/hemilabs/heminetwork/api" +) + +var ( + promiseConstructor = js.Global().Get("Promise") + objectConstructor = js.Global().Get("Object") + 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()) + } + 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)) + } + } + return obj +} + +// jsValueSafe wraps js.ValueOf and recovers when js.ValueOf panics due to it +// not being able to handle the type it is called with. js.Undefined() is +// returned when a panic occurs and an error is logged. +func jsValueSafe(v any) (jsv js.Value) { + defer func() { + if r := recover(); r != nil { + log.Errorf("recovered from js.ValueOf panic: %v: %T", r, v) + jsv = js.Undefined() + } + }() + return js.ValueOf(v) +} + +// jsError returns a [js.Value] representing the given error. +func jsError(err error) js.Value { + log.Tracef("jsError: %v", err) + defer log.Tracef("jsError exit") + + stack := string(debug.Stack()) + return Object{ + "message": err.Error(), + "stack": stack, + "timestamp": time.Now().Unix(), + }.Value() +} diff --git a/web/www/index.html b/web/www/index.html index acbbe02f..dc6a89d9 100644 --- a/web/www/index.html +++ b/web/www/index.html @@ -15,8 +15,8 @@

- - + +

@@ -29,23 +29,23 @@

- - + +
- - + +
- - + +
- - + +
- - + +

@@ -53,9 +53,9 @@

- +
-
The following commands require a live connection (after RunPopMinerButton)
+
The following commands require a live connection (after StartPopMinerButton)

diff --git a/web/www/index.js b/web/www/index.js index 60bbe2d8..975fa5e7 100644 --- a/web/www/index.js +++ b/web/www/index.js @@ -4,24 +4,37 @@ * which can be found in the LICENSE file. */ -// wasm ping -const WASMPingShow = document.querySelector('.WASMPingShow'); +let wasm; // This stores the global object created by the WASM binary. -async function WASMPing() { +// Called after the WASM binary has been loaded. +async function init() { + wasm = globalThis['@hemilabs/pop-miner']; +} + +async function dispatch(args) { + if (!wasm) { + throw new Error('WASM has not finished loading yet'); + } + return wasm.dispatch(args); +} + +// version +const VersionShow = document.querySelector('.VersionShow'); + +async function Version() { try { const result = await dispatch({ - method: 'wasmping', - message: 'wasm ping', + method: 'version', }); - WASMPingShow.innerHTML = result; + VersionShow.innerText = JSON.stringify(result); } catch (err) { - WASMPingShow.innerHTML = err; + VersionShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } -WASMPingButton.addEventListener('click', () => { - WASMPing(); +VersionButton.addEventListener('click', () => { + Version(); }); // generate key @@ -30,12 +43,12 @@ const GenerateKeyShow = document.querySelector('.GenerateKeyShow'); async function GenerateKey() { try { const result = await dispatch({ - method: 'generatekey', - network: GenerateKeyNetworkInput.value, + method: 'generateKey', + network: GenerateKeyNetworkInput.value, }); - GenerateKeyShow.innerHTML = result; + GenerateKeyShow.innerText = JSON.stringify(result); } catch (err) { - GenerateKeyShow.innerHTML = err; + GenerateKeyShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -44,27 +57,27 @@ GenerateKeyButton.addEventListener('click', () => { GenerateKey(); }); -// run pop miner -const RunPopMinerShow = document.querySelector('.RunPopMinerShow'); +// start pop miner +const StartPopMinerShow = document.querySelector('.StartPopMinerShow'); -async function RunPopMiner() { +async function StartPopMiner() { try { const result = await dispatch({ - method: 'runpopminer', - network: RunPopMinerNetworkInput.value, - logLevel: RunPopMinerLogLevelInput.value, - privateKey: RunPopMinerPrivateKeyInput.value, - staticFee: Number(RunPopMinerStaticFeeInput.value), + method: 'startPoPMiner', + network: StartPopMinerNetworkInput.value, + logLevel: StartPopMinerLogLevelInput.value, + privateKey: StartPopMinerPrivateKeyInput.value, + staticFee: Number(StartPopMinerStaticFeeInput.value), }); - RunPopMinerShow.innerHTML = result; + StartPopMinerShow.innerText = JSON.stringify(result); } catch (err) { - RunPopMinerShow.innerHTML = err; + StartPopMinerShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } -RunPopMinerButton.addEventListener('click', () => { - RunPopMiner(); +StartPopMinerButton.addEventListener('click', () => { + StartPopMiner(); }); // stop pop miner @@ -73,11 +86,11 @@ const StopPopMinerShow = document.querySelector('.StopPopMinerShow'); async function StopPopMiner() { try { const result = await dispatch({ - method: 'stoppopminer', + method: 'stopPoPMiner', }); - StopPopMinerShow.innerHTML = result; + StopPopMinerShow.innerText = JSON.stringify(result); } catch (err) { - StopPopMinerShow.innerHTML = err; + StopPopMinerShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -92,12 +105,11 @@ const PingShow = document.querySelector('.PingShow'); async function Ping() { try { const result = await dispatch({ - method: 'ping', - timestamp: 0, // XXX pull timestamp + method: 'ping', // Timestamp is handled by Go. }); - PingShow.innerHTML = result; + PingShow.innerText = JSON.stringify(result); } catch (err) { - PingShow.innerHTML = err; + PingShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -115,9 +127,9 @@ async function L2Keystones() { method: 'l2Keystones', numL2Keystones: Number(L2KeystonesNumL2KeystonesInput.value), }); - L2KeystonesShow.innerHTML = result; + L2KeystonesShow.innerText = JSON.stringify(result); } catch (err) { - L2KeystonesShow.innerHTML = err; + L2KeystonesShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -133,11 +145,11 @@ async function BitcoinBalance() { try { const result = await dispatch({ method: 'bitcoinBalance', - scriptHash: BitcoinBalanceScriptHashInput.value, + scriptHash: BitcoinBalanceScriptHashInput.value, }); - BitcoinBalanceShow.innerHTML = result; + BitcoinBalanceShow.innerText = JSON.stringify(result); } catch (err) { - BitcoinBalanceShow.innerHTML = err; + BitcoinBalanceShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -154,9 +166,9 @@ async function BitcoinInfo() { const result = await dispatch({ method: 'bitcoinInfo', }); - BitcoinInfoShow.innerHTML = result; + BitcoinInfoShow.innerText = JSON.stringify(result); } catch (err) { - BitcoinInfoShow.innerHTML = err; + BitcoinInfoShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -171,12 +183,12 @@ const BitcoinUTXOsShow = document.querySelector('.BitcoinUTXOsShow'); async function BitcoinUTXOs() { try { const result = await dispatch({ - method: 'bitcoinUtxos', - scriptHash: BitcoinUTXOsScriptHashInput.value, + method: 'bitcoinUTXOs', + scriptHash: BitcoinUTXOsScriptHashInput.value, }); - BitcoinUTXOsShow.innerHTML = result; + BitcoinUTXOsShow.innerText = JSON.stringify(result); } catch (err) { - BitcoinUTXOsShow.innerHTML = err; + BitcoinUTXOsShow.innerText = 'error: ' + JSON.stringify(err); console.error('Caught exception', err); } } @@ -184,4 +196,3 @@ async function BitcoinUTXOs() { BitcoinUTXOsButton.addEventListener('click', () => { BitcoinUTXOs(); }); - diff --git a/web/www/popminer.js b/web/www/popminer.js index e4917eab..f02d5c4a 100644 --- a/web/www/popminer.js +++ b/web/www/popminer.js @@ -20,9 +20,7 @@ WebAssembly.instantiateStreaming(fetch("popminer.wasm"), go.importObject).then(( // Always launch go runtime go.run(inst); -}).catch((err) => { +}).then(() => init()).catch((err) => { // XXX restart wasm instead console.error(err); }); - -console.error("hi there"); diff --git a/web/www/wasm_exec.js b/web/www/wasm_exec.js new file mode 100644 index 00000000..1090d3b0 --- /dev/null +++ b/web/www/wasm_exec.js @@ -0,0 +1,563 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Go LICENSE file: https://github.com/golang/go/blob/master/LICENSE + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); From d110ec41dba35c45fce3d6343383cb0cca152d20 Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Sat, 8 Jun 2024 02:40:26 +1000 Subject: [PATCH 2/7] web: use absolute paths in makefile --- web/Makefile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/Makefile b/web/Makefile index 8586d49a..b721fee5 100644 --- a/web/Makefile +++ b/web/Makefile @@ -6,6 +6,7 @@ PROJECTPATH = $(abspath $(dir $(realpath $(firstword $(MAKEFILE_LIST))))) GOROOT=$(shell go env GOROOT) WEBAPP=$(PROJECTPATH)/webapp +WWW_DIR=$(PROJECTPATH)/www WASM_BINARY=$(WEBAPP)/popminer.wasm version = $(patsubst v%,%,$(shell git describe --tags 2>/dev/null || echo "v0.0.0")) @@ -23,11 +24,11 @@ clean: wasm: CGO_ENABLED=0 GOOS=js GOARCH=wasm go build -trimpath -tags "$(BUILD_TAGS)" \ -ldflags "-s -w -X main.version=${version} -X main.gitCommit=${commit}" \ - -o ${WASM_BINARY} ./popminer/... + -o ${WASM_BINARY} ${PROJECTPATH}/popminer/... www: wasm mkdir -p ${WEBAPP} - cp www/index.html ${WEBAPP} - cp www/index.js ${WEBAPP} - cp www/popminer.js ${WEBAPP} - cp www/wasm_exec.js ${WEBAPP} + cp ${WWW_DIR}/index.html ${WEBAPP} + cp ${WWW_DIR}/index.js ${WEBAPP} + cp ${WWW_DIR}/popminer.js ${WEBAPP} + cp ${WWW_DIR}/wasm_exec.js ${WEBAPP} From 29cb3f3658e561fd10eb52c0b5336298a35ac98c Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Sat, 8 Jun 2024 02:43:33 +1000 Subject: [PATCH 3/7] popm/wasm: set default log level to 'WARN' --- web/popminer/popminer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/popminer/popminer.go b/web/popminer/popminer.go index 26c6b02e..c62b2d36 100644 --- a/web/popminer/popminer.go +++ b/web/popminer/popminer.go @@ -25,7 +25,7 @@ var ( version = "" gitCommit = "" - logLevel = "ERROR" // Can be set with `-ldflags "-X main.logLevel=TRACE"` + logLevel = "WARN" // Can be set with `-ldflags "-X main.logLevel=TRACE"` log = loggo.GetLogger("@hemilabs/pop-miner") ) From 6b03f7901d5e31f30dbd0be276b65ff9f5a0658d Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Wed, 12 Jun 2024 03:52:06 +1000 Subject: [PATCH 4/7] popm/wasm: add comment to dcrsecp256k1 pkg usage --- web/popminer/dispatch.go | 1 + 1 file changed, 1 insertion(+) diff --git a/web/popminer/dispatch.go b/web/popminer/dispatch.go index a64c5817..c7a943c2 100644 --- a/web/popminer/dispatch.go +++ b/web/popminer/dispatch.go @@ -212,6 +212,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. privKey, err := dcrsecpk256k1.GeneratePrivateKey() if err != nil { log.Errorf("failed to generate private key: %v", err) From 5826501eaf6c374f9d0041cbeb66b9b47819cfcb Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Fri, 14 Jun 2024 02:48:04 +1000 Subject: [PATCH 5/7] 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 @@

- +
From 34052a9073de19e5e295416a0ad61d38bac872bb Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Fri, 14 Jun 2024 03:31:25 +1000 Subject: [PATCH 6/7] popmd/wasm: add missing copyright header and go:build directive --- web/popminer/api.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/popminer/api.go b/web/popminer/api.go index 3911eba2..ba4f3ab2 100644 --- a/web/popminer/api.go +++ b/web/popminer/api.go @@ -1,3 +1,9 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + package main // Method represents a method that can be dispatched. From 396b6ea15d694d69dbf39a869a47b360accc98af Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Fri, 14 Jun 2024 23:19:17 +1000 Subject: [PATCH 7/7] popmd/wasm: rename unused 'this' param to '_' --- web/popminer/dispatch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/popminer/dispatch.go b/web/popminer/dispatch.go index 898f553e..e0b02875 100644 --- a/web/popminer/dispatch.go +++ b/web/popminer/dispatch.go @@ -274,7 +274,7 @@ func startPoPMiner(_ js.Value, args []js.Value) (any, error) { // TODO(joshuasing): dispatch event on failure pmMtx.Lock() defer pmMtx.Unlock() - pm.err = err // Theoretically this can logic race unless we unset om + pm.err = err // Theoretically this can logic race unless we unset pm } }() @@ -401,7 +401,7 @@ func bitcoinInfo(_ js.Value, _ []js.Value) (any, error) { }, nil } -func bitcoinUTXOs(this js.Value, args []js.Value) (any, error) { +func bitcoinUTXOs(_ js.Value, args []js.Value) (any, error) { log.Tracef("bitcoinUTXOs") defer log.Tracef("bitcoinUTXOs exit")