commit db7464d2d378e4de7060b560a10dafc5c3d480e2 Author: Alex Vanin Date: Wed Sep 27 14:05:46 2023 +0300 Initial commit Signed-off-by: Alex Vanin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36f971e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cbc9f19 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 TrueCloudLab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7ffab92 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: build + +build: + CGO_ENABLED=0 go build -o ./bin/monza ./ diff --git a/README.md b/README.md new file mode 100644 index 0000000..291df0b --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# Monza + +Find notifications in [Neo3](https://neo.org/) compatible chains and more. + +## Features + +- Stores chain blocks in the filesystem, so it will not fetch it again + at restart +- Works with multiple chains by storing chain blocks based on magic number +- Detailed notification output for NEP and FrostFS notifications +- Use relative numbers for search interval +- Use nice names for native contracts +- Search multiple notifications at once + +## Examples + +``` +$ monza run -r http://localhost:30333 --from 22 --to 24 -n NewEpoch:* -n Transfer:gas +syncing 100% [##################################################] (2/2, 3686 blocks/s) +block:22 at:2023-09-27T13:50:03+03:00 name:Transfer from:56c989e76f9a2ca05bb5caa6c96f524d905accd8 to:nil amount:69915670 +block:22 at:2023-09-27T13:50:03+03:00 name:Transfer from:nil to:b248508f4ef7088e10c48f14d04be3272ca29eee amount:1219580 +block:22 at:2023-09-27T13:50:03+03:00 name:Transfer from:nil to:b248508f4ef7088e10c48f14d04be3272ca29eee amount:50000000 +block:22 at:2023-09-27T13:50:03+03:00 name:NewEpoch epoch:1 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:05195d17c8f013e258eb8dde1236c19d9a61b608 to:nil amount:33917200 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:6a131b2be19b7618dc22dbc8147015d947af67ce to:nil amount:12321080 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:b248508f4ef7088e10c48f14d04be3272ca29eee to:nil amount:12325080 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:nil to:b248508f4ef7088e10c48f14d04be3272ca29eee amount:8917170 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:nil to:b248508f4ef7088e10c48f14d04be3272ca29eee amount:20000000 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:nil to:b248508f4ef7088e10c48f14d04be3272ca29eee amount:50000000 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:6a131b2be19b7618dc22dbc8147015d947af67ce to:c1e14f19c3e60d0b9244d06dd7ba9b113135ec3b amount:243839460 +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:b248508f4ef7088e10c48f14d04be3272ca29eee to:c1e14f19c3e60d0b9244d06dd7ba9b113135ec3b amount:250146763165 +``` + +### Detailed output + +- NEP-17 `Transfer` + +``` +block:23 at:2023-09-27T13:50:04+03:00 name:Transfer from:6a131b2be19b7618dc22dbc8147015d947af67ce to:c1e14f19c3e60d0b9244d06dd7ba9b113135ec3b amount:243839460 +``` + +- FrostFS `NewEpoch` + +``` +block:22 at:2023-09-27T13:50:03+03:00 name:NewEpoch epoch:1 +``` + +- FrostFS `AddPeerSuccess` + +``` +block:21 at:2023-09-27T13:50:02+03:00 name:AddPeerSuccess pubkey:[..6a8131] +``` + +### Notifications + +Search notifications based on notification name and contract address. + +``` +monza run -r [endpoint] --from 110000 --to 110100 -n NewEpoch:ab8a83432af3cd32ce6ba3797f62b1ba330d7c3d +``` + +Use wildcard to search notifications from any contract. + +``` +monza run -r [endpoint] --from 110000 --to 110100 -n NewEpoch:* +``` + +Use native contract names such as `gas` and `neo`. + +``` +monza run -r [endpoint] --from 110000 --to 110100 -n Transfer:gas +``` + +Search for multiple notifications. + +``` +monza run -r [endpoint] --from 110000 --to 110100 -n Transfer:gas -n NewEpoch:* +``` + +### Intervals + +Define start and stop blocks. + +``` +monza run -r [endpoint] --from 110000 --to 110100 -n NewEpoch:* +``` + +Omit `--to` flag to search up to the latest block. + +``` +monza run -r [endpoint] --from 110000 -n NewEpoch:* +``` + +To look for `100` blocks before the latest block use prefix `m` (minus) + +``` +monza run -r [endpoint] --from m100 -n NewEpoch:* +``` + +To look for `100` blocks after specified `from` block, use prefix `p` (plus) + +``` +monza run -r [endpoint] --from 101230 --to p100 -n NewEpoch:* +``` + +### Other + +Blocks are stored in bolt databases. Specify database dir with `-c` flag +(default path is `$HOME/.config/monza`) + +``` +monza run -r [endpoint] -c ./cache --from 110000 --to 110100 -n NewEpoch:* +``` + +To speed up block fetching from the RPC node, use more parallel workers with +`-w` flag. + +``` +monza run -r [endpoint] -w 100 --from 110000 --to 110100 -n NewEpoch:* +``` + +To disable progress bar use `--disable-progress-bar` flag. + +### Stutter + +Stutter command searches for blocks that produced with threshold a delay +or slower. + +``` +$ monza stutter -r [endpoint] --from 1159200 --to p30 --threshold 20s +syncing 100% [##################################################] (30/30, 10 blocks/s) +block:1159201 at:2022-03-29T18:20:42+03:00 +block:1159202 at:2022-03-29T18:22:12+03:00 [<- stutter for 1m30s] +-- skipped 5 blocks -- +block:1159208 at:2022-03-29T18:23:43+03:00 +block:1159209 at:2022-03-29T18:25:13+03:00 [<- stutter for 1m30s] +-- skipped 5 blocks -- +block:1159215 at:2022-03-29T18:26:43+03:00 +block:1159216 at:2022-03-29T18:28:13+03:00 [<- stutter for 1m30s] +-- skipped 5 blocks -- +block:1159222 at:2022-03-29T18:29:43+03:00 +block:1159223 at:2022-03-29T18:31:13+03:00 [<- stutter for 1m30s] +``` + +### Explorer + +Run monza in interactive mode to navigate through blocks, transactions and +notifications with `explore` command. + +``` +$ monza explore -r [endpoint] +``` + +## Build + +Use `make build` command. Binary will be stored in `./bin/monza`. + +## To Do + +- [ ] `monza cache` command to manage bbolt instances: provide size and option to delete +- [ ] Add verbose flag with for detailed view of notification body +- [ ] Add more native contract hashes aliases +- [ ] More NEP support (NEP-11?) + +## License + +Source code is available under the [MIT License](/LICENSE). diff --git a/encoding.go b/encoding.go new file mode 100644 index 0000000..302dac8 --- /dev/null +++ b/encoding.go @@ -0,0 +1,194 @@ +package main + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" +) + +const nonCompatibleMsg = "not compatible with FrostFS" + +func PrintEvent(b *block.Block, n state.NotificationEvent, extra string) { + d := time.Unix(int64(b.Timestamp/1e3), 0) + s := fmt.Sprintf("block:%d at:%s name:%s", + b.Index, d.Format(time.RFC3339), n.Name, + ) + + if len(extra) != 0 { + s += fmt.Sprintf(" [%s]", extra) + } + + fmt.Println(s) +} + +func PrintTransfer(b *block.Block, n state.NotificationEvent) { + const nonCompatibleMsg = "not NEP-17 compatible" + + items, ok := n.Item.Value().([]stackitem.Item) + if !ok { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + if len(items) != 3 { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + snd, err := items[0].TryBytes() + if err != nil { + snd = nil + } + + rcv, err := items[1].TryBytes() + if err != nil { + rcv = nil + } + + bigAmount, err := items[2].TryInteger() + if err != nil { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + var sndStr, rcvStr = "nil", "nil" + if snd != nil { + sndStr = hex.EncodeToString(revertBytes(snd)) + } + + if rcv != nil { + rcvStr = hex.EncodeToString(revertBytes(rcv)) + } + + d := time.Unix(int64(b.Timestamp/1e3), 0) + + s := fmt.Sprintf("block:%d at:%s name:%s from:%s to:%s amount:%d", + b.Index, d.Format(time.RFC3339), n.Name, + sndStr, rcvStr, bigAmount.Int64(), + ) + + fmt.Println(s) +} + +func PrintNewEpoch(b *block.Block, n state.NotificationEvent) { + items, ok := n.Item.Value().([]stackitem.Item) + if !ok { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + if len(items) != 1 { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + epoch, err := items[0].TryInteger() + if err != nil { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + d := time.Unix(int64(b.Timestamp/1e3), 0) + + s := fmt.Sprintf("block:%d at:%s name:%s epoch:%d", + b.Index, d.Format(time.RFC3339), n.Name, epoch, + ) + + fmt.Println(s) +} + +func PrintAddPeerSuccess(b *block.Block, n state.NotificationEvent) { + items, ok := n.Item.Value().([]stackitem.Item) + if !ok { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + if len(items) != 1 { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + data, err := items[0].TryBytes() + if err != nil { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + d := time.Unix(int64(b.Timestamp/1e3), 0) + + s := fmt.Sprintf("block:%d at:%s name:%s pubkey:[..%s]", + b.Index, d.Format(time.RFC3339), n.Name, + hex.EncodeToString(data[len(data)-3:]), + ) + + fmt.Println(s) +} + +func PrintUpdateState(b *block.Block, n state.NotificationEvent) { + items, ok := n.Item.Value().([]stackitem.Item) + if !ok { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + if len(items) != 2 { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + st, err := items[0].TryInteger() + if err != nil { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + var stateStr string + switch v := st.Uint64(); v { + case 1: + stateStr = "online" + case 2: + stateStr = "offline" + default: + stateStr = fmt.Sprintf("%d(unknown)", v) + } + + pubkey, err := items[1].TryBytes() + if err != nil { + PrintEvent(b, n, nonCompatibleMsg) + return + } + + d := time.Unix(int64(b.Timestamp/1e3), 0) + + s := fmt.Sprintf("block:%d at:%s name:%s pubkey:[..%s] state:%s", + b.Index, d.Format(time.RFC3339), n.Name, + hex.EncodeToString(pubkey[len(pubkey)-3:]), + stateStr, + ) + + fmt.Println(s) +} + +func PrintBlock(b *block.Block, extra string) { + d := time.Unix(int64(b.Timestamp/1e3), 0) + s := fmt.Sprintf("block:%d at:%s", b.Index, d.Format(time.RFC3339)) + + if len(extra) != 0 { + s += fmt.Sprintf(" [%s]", extra) + } + + fmt.Println(s) +} + +func revertBytes(data []byte) []byte { + ln := len(data) + for i := 0; i < ln/2; i++ { + data[i], data[ln-1-i] = data[ln-1-i], data[i] + } + return data +} diff --git a/explorer.go b/explorer.go new file mode 100644 index 0000000..fda0804 --- /dev/null +++ b/explorer.go @@ -0,0 +1,508 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "time" + + "git.frostfs.info/TrueCloudLab/monza/internal/chain" + "github.com/gdamore/tcell/v2" + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/rivo/tview" + "github.com/urfave/cli/v2" +) + +type ( + Explorer struct { + ctx context.Context + chain *chain.Chain + endpoint string + app *tview.Application + + jobCh chan fetchTask + errCh chan error + wg sync.WaitGroup + + searchErrFlag bool + } + + fetchTask struct { + txHash *util.Uint256 + index uint32 + } +) + +const defaultExploreWorkers = 100 + +func explorer(c *cli.Context) (err error) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + + // parse blockchain info + cacheDir := c.String(cacheFlagKey) + if len(cacheDir) == 0 { + cacheDir, err = defaultConfigDir() + if err != nil { + return err + } + } + + endpoint := c.String(endpointFlagKey) + blockchain, err := chain.Open(ctx, cacheDir, endpoint) + if err != nil { + return fmt.Errorf("cannot initialize remote blockchain client: %w", err) + } + defer func() { + blockchain.Close() + cancel() + }() + + e := Explorer{ + ctx: ctx, + chain: blockchain, + endpoint: endpoint, + app: tview.NewApplication(), + jobCh: make(chan fetchTask), + errCh: make(chan error), + } + e.startWorkers(defaultExploreWorkers) + return e.Run() +} + +func (e *Explorer) Run() error { + // UI basic elements + blockList := tview.NewList() + searchInput := tview.NewInputField() + blockInfo := tview.NewTextView() + txCounter := tview.NewTextView() + txList := tview.NewList() + notifications := tview.NewTextView() + + // UI containers + blockFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(blockInfo, 0, 1, false). + AddItem(txCounter, 2, 2, false). + AddItem(txList, 5, 3, false) + appGrid := tview.NewGrid(). + SetRows(15, -1, 1). + SetColumns(-1, -2). + AddItem(blockList, 0, 0, 2, 1, 0, 0, false). + AddItem(searchInput, 2, 0, 1, 2, 0, 0, false). + AddItem(blockFlex, 0, 1, 1, 1, 0, 0, false). + AddItem(notifications, 1, 1, 1, 1, 0, 0, false) + + // Initialize element style + blockList.ShowSecondaryText(false).SetWrapAround(false) + blockList.SetBorder(true).SetTitle("Blocks") + searchInput.SetFieldBackgroundColor(tcell.ColorBlack) + txList.ShowSecondaryText(false) + setFocusColorStyle(blockList.Box, blockList.Box) + setFocusColorStyle(blockFlex.Box, txList.Box) + setFocusColorStyle(notifications.Box, notifications.Box) + + // Handle redrawing events + blockList.SetDrawFunc(func(_ tcell.Screen, _, _, _, _ int) (int, int, int, int) { + posX, posY, width, height := blockList.GetInnerRect() + e.redrawBlockList(blockList, height) + return posX, posY, width, height + }) + + // Handle non-default keyboard events + appGrid.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == '/' { + e.searchStatusBar(searchInput) + e.app.SetFocus(searchInput) + return nil + } + return event + }) + blockList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'q': + e.app.Stop() + return nil + case 'r': + e.refillBlockList(blockList) + return nil + default: + return event + } + }) + searchInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if !e.searchErrFlag { + return event + } + e.searchErrFlag = false + e.defaultStatusBar(searchInput, blockList.GetItemCount()) + e.app.SetFocus(blockList) + return nil + }) + txList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyTAB || event.Key() == tcell.KeyEnter { + e.app.SetFocus(notifications) + return nil + } + if event.Rune() == 'q' { + e.hideBlockFlex(blockFlex, blockInfo, txCounter, notifications, txList) + e.app.SetFocus(blockList) + return nil + } + return event + }) + notifications.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyTAB || event.Rune() == 'q' { + e.app.SetFocus(txList) + return nil + } + return event + }) + + // Handle search input logic + searchInput.SetDoneFunc(func(key tcell.Key) { + e.hideBlockFlex(blockFlex, blockInfo, txCounter, notifications, txList) + e.refillBlockList(blockList) + + input := searchInput.GetText() + blockCount := blockList.GetItemCount() + + // Try parse as lookingBlock index + lookingBlock, err := strconv.Atoi(input) + if err == nil { + if lookingBlock < 0 || lookingBlock >= blockCount { + e.errorStatusBar(searchInput, "invalid block number") + return + } + from, to := blockIndexRange(lookingBlock, blockCount, 50) + e.cacheBlocks(from, to) + e.defaultStatusBar(searchInput, blockCount) + blockList.SetCurrentItem(-lookingBlock - 1) + e.app.SetFocus(blockList) + return + } + // Try parse as transaction hash + h, err := util.Uint256DecodeStringLE(input) + if err == nil { + appLog, err := e.chain.Client.GetApplicationLog(h, nil) + if err != nil { + e.errorStatusBar(searchInput, "tx hash not found") + return + } + chainBlock, err := e.chain.BlockByHash(appLog.Container) + if err != nil { + e.errorStatusBar(searchInput, "can't get block of specified tx") + return + } + from, to := blockIndexRange(int(chainBlock.Index), blockCount, 50) + e.cacheBlocks(from, to) + e.defaultStatusBar(searchInput, blockCount) + blockList.SetCurrentItem(-int(chainBlock.Index) - 1) + e.app.SetFocus(blockList) + return + } + e.errorStatusBar(searchInput, "invalid input, expect valid block number or tx hash") + }) + + // Handle selecting block in block list + blockList.SetSelectedFunc(func(i int, s1, s2 string, r rune) { + chainBlock, err := e.chain.Block(uint32(blockList.GetItemCount() - i - 1)) + if err != nil { + panic(err) + } + e.cacheNotifications(chainBlock) + e.displayBlockFlex(blockFlex, blockInfo, txCounter, notifications, txList, chainBlock) + e.app.SetFocus(txList) + }) + + // Handle select transaction in block flex + txList.SetChangedFunc(func(index int, mainText, secondaryText string, shortcut rune) { + txCounter.SetText(fmt.Sprintf("Transaction %d of %d\n---", index+1, txList.GetItemCount())) + txHash, err := util.Uint256DecodeStringLE(mainText) + if err != nil { + panic(err) + } + appLog, err := e.chain.ApplicationLog(txHash) + if err != nil { + panic(err) + } + + events := make([]state.NotificationEvent, 0) + for _, execution := range appLog.Executions { + events = append(events, execution.Events...) + } + + var res string + for _, event := range events { + v, err := formatNotification(event) + if err != nil { + continue + } + res += v + } + notifications.SetText(res) + notifications.ScrollToBeginning() + }) + + // Initialize element data + e.fillBlockList(blockList) + e.defaultStatusBar(searchInput, blockList.GetItemCount()) + + // Start UI + e.app.SetRoot(appGrid, true).SetFocus(blockList) + return e.app.Run() +} + +func (e *Explorer) fillBlockList(list *tview.List) { + to, err := e.chain.Client.GetBlockCount() + if err != nil { + panic(err) + } + + // adding is much faster than inserting + // so we reverse the order to use that + for i := int64(to) - 1; i >= 0; i-- { + line := fmt.Sprintf("#%d", i) + list.AddItem(line, "", 0, nil) + } + + // cache some blocks upfront for smoother scrolling + var from uint32 + if to > 100 { + from = to - 100 + } + e.cacheBlocks(from, to-1) +} + +func (e *Explorer) refillBlockList(list *tview.List) { + from := uint32(list.GetItemCount()) + + to, err := e.chain.Client.GetBlockCount() + if err != nil { + panic(err) + } + + for i := from; i < to; i++ { + line := fmt.Sprintf("#%d", i) + list.InsertItem(0, line, "", 0, nil) + } + + e.cacheBlocks(from, to-1) +} + +func (e *Explorer) redrawBlockList(list *tview.List, height int) { + currentItemIndex := list.GetCurrentItem() + itemCount := list.GetItemCount() + + // range of blocks for detailed info in listbox + // to avoid whole blockchain fetching, app works + // with small ranges of visible blocks on the screen + fromIndex := currentItemIndex - height + if fromIndex < 0 { + fromIndex = 0 + } + + toIndex := currentItemIndex + height + if toIndex > itemCount { + toIndex = itemCount + } + + e.cacheBlocks(toBlockIndex(toIndex, itemCount), toBlockIndex(fromIndex, itemCount)) + for i := fromIndex; i < toIndex; i++ { + elem, _ := list.GetItemText(i) + // ignore blocks that has been parsed + if strings.Contains(elem, "tx") { + continue + } + blockIndex := toBlockIndex(i, itemCount) + chainBlock, err := e.chain.Block(blockIndex) + if err != nil { + panic(err) + } + ts := time.Unix(int64(chainBlock.Timestamp/1e3), 0) + richText := fmt.Sprintf("#%d [%s] txs:%d", blockIndex, ts.Format(time.RFC3339), len(chainBlock.Transactions)) + list.SetItemText(i, richText, "") + } +} + +func (e *Explorer) startWorkers(amount int) { + worker := func(ctx context.Context, ch <-chan fetchTask, out chan<- error) { + for { + select { + case <-ctx.Done(): + return + case task, ok := <-ch: + if !ok { + return + } + var err error + if task.txHash != nil { + _, err = e.chain.ApplicationLog(*task.txHash) + } else { + _, err = e.chain.Block(task.index) + } + if err != nil { + out <- err + return + } + e.wg.Done() + } + } + } + + for i := 0; i < amount; i++ { + go worker(e.ctx, e.jobCh, e.errCh) + } +} + +func (e *Explorer) cacheBlocks(from, to uint32) { + for i := from; i <= to; i++ { + e.wg.Add(1) + select { + case <-e.ctx.Done(): + return + case err := <-e.errCh: + panic(err) + case e.jobCh <- fetchTask{index: i}: + } + } + + wgCh := make(chan struct{}) + + go func() { + e.wg.Wait() + close(wgCh) + }() + + select { + case <-e.ctx.Done(): + return + case err := <-e.errCh: + panic(err) + case <-wgCh: + return + } +} + +func (e *Explorer) cacheNotifications(block *block.Block) { + for _, tx := range block.Transactions { + h := tx.Hash() + e.wg.Add(1) + select { + case <-e.ctx.Done(): + return + case err := <-e.errCh: + panic(err) + case e.jobCh <- fetchTask{txHash: &h}: + } + } + + wgCh := make(chan struct{}) + + go func() { + e.wg.Wait() + close(wgCh) + }() + + select { + case <-e.ctx.Done(): + return + case err := <-e.errCh: + panic(err) + case <-wgCh: + return + } +} + +func (e *Explorer) defaultStatusBar(input *tview.InputField, blocks int) { + message := fmt.Sprintf("Endpoint: %s Blocks: %d | Press q to back, / to search, r to resync.", + e.endpoint, + blocks) + input.SetText(""). + SetLabelColor(tcell.ColorWhite). + SetLabel(message) +} + +func (e *Explorer) searchStatusBar(input *tview.InputField) { + input.SetText(""). + SetLabelColor(tcell.ColorGreen). + SetLabel("Search block or transaction: ") +} + +func (e *Explorer) errorStatusBar(input *tview.InputField, message string) { + e.searchErrFlag = true + input.SetText(""). + SetLabelColor(tcell.ColorRed). + SetLabel(message) +} + +func (e *Explorer) displayBlockFlex(f *tview.Flex, info, counter, notif *tview.TextView, list *tview.List, b *block.Block) { + ln := min(len(b.Transactions), 7) + for _, tx := range b.Transactions { + list.AddItem(tx.Hash().StringLE(), "", 0, nil) + } + info.SetText(fmt.Sprintf("Exploring block #%d", b.Index)) + counter.SetText(fmt.Sprintf("Transaction %d of %d\n---", min(1, ln), list.GetItemCount())) + notif.SetBorder(true).SetTitle("Notifications") + f.Clear() + f.SetBorder(true) + f.AddItem(info, 0, 1, false) + f.AddItem(counter, 2, 2, false) + f.AddItem(list, ln, 3, false) +} + +func (e *Explorer) hideBlockFlex(flex *tview.Flex, info, counter, notif *tview.TextView, list *tview.List) { + info.SetText("") + counter.SetText("") + list.Clear() + flex.SetBorder(false) + notif.SetText("").SetBorder(false).SetTitle("") +} + +func toBlockIndex(index, length int) uint32 { + return uint32(length - index - 1) +} + +func blockIndexRange(index, length, delta int) (uint32, uint32) { + from := index - delta + to := index + delta + if from < 0 { + from = 0 + } + if to >= length { + to = length - 1 + } + return uint32(from), uint32(to) +} + +func formatNotification(event state.NotificationEvent) (string, error) { + data, err := stackitem.ToJSONWithTypes(event.Item) + if err != nil { + return "", err + } + var formatted bytes.Buffer + err = json.Indent(&formatted, data, "", " ") + if err != nil { + return "", err + } + return fmt.Sprintf("%s\n---\n%s\n\n", event.Name, formatted.String()), nil +} + +func setFocusColorStyle(target, focus *tview.Box) { + focus.SetFocusFunc(func() { + target.SetBorderColor(tcell.ColorGreen) + target.SetBorderAttributes(tcell.AttrBold) + }) + focus.SetBlurFunc(func() { + target.SetBorderColor(tcell.ColorDefault) + target.SetBorderAttributes(tcell.AttrNone) + }) +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..be06eaf --- /dev/null +++ b/flags.go @@ -0,0 +1,176 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" + "github.com/nspcc-dev/neo-go/pkg/rpcclient" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/urfave/cli/v2" +) + +const ( + endpointFlagKey = "rpc-endpoint" + fromFlagKey = "from" + toFlagKey = "to" + notificationFlagKey = "notification" + cacheFlagKey = "cache" + workersFlagKey = "workers" + disableProgressBarFlagKey = "disable-progress-bar" + stutterThresholdFlagKey = "threshold" +) + +var ( + endpointFlag = &cli.StringFlag{ + Name: endpointFlagKey, + Aliases: []string{"r"}, + Usage: "N3 RPC endpoint", + Required: true, + } + + fromFlag = &cli.StringFlag{ + Name: fromFlagKey, + Usage: "starting block (can be relative value with minus prefix, e.g. 'm100')", + Required: true, + Value: "", + } + + toFlag = &cli.StringFlag{ + Name: toFlagKey, + Usage: "ending block (can be relative value with plus prefix, e.g. 'p100' or omitted for latest block in chain)", + Required: false, + Value: "", + } + + notificationFlag = &cli.StringSliceFlag{ + Name: notificationFlagKey, + Aliases: []string{"n"}, + Usage: "'notification:contract' pair (specify LE script hash, '*' for any contract or 'gas' and 'neo' strings)", + Required: true, + Value: nil, + } + + cacheFlag = &cli.StringFlag{ + Name: cacheFlagKey, + Aliases: []string{"c"}, + Usage: "path to the blockchain cache (default: $HOME/.config/monza)", + Value: "", + } + + workersFlag = &cli.Uint64Flag{ + Name: workersFlagKey, + Aliases: []string{"w"}, + Usage: "amount of workers for parallel block fetch", + Value: 3, + } + + disableProgressBarFlag = &cli.BoolFlag{ + Name: disableProgressBarFlagKey, + Usage: "disable progress bar output", + } + + stutterThresholdFlag = &cli.DurationFlag{ + Name: stutterThresholdFlagKey, + Aliases: []string{"t"}, + Usage: "duration limit between block timestamps", + Value: 20 * time.Second, + } +) + +func parseNotifications(notifications []string, cli *rpcclient.Client) (map[string]*util.Uint160, error) { + res := make(map[string]*util.Uint160, len(notifications)) + + for _, n := range notifications { + pair := strings.Split(n, ":") + if len(pair) != 2 { + return nil, fmt.Errorf("invalid notification %s", n) + } + + name := pair[0] + + switch contractName := strings.ToLower(pair[1]); contractName { + case "*": + res[name] = nil + case "gas": + state, err := cli.GetContractStateByAddressOrName(nativenames.Gas) + if err != nil { + return nil, fmt.Errorf("invalid contract name %s", contractName) + } + res[name] = &state.Hash + case "neo": + state, err := cli.GetContractStateByAddressOrName(nativenames.Neo) + if err != nil { + return nil, fmt.Errorf("invalid contract name %s", contractName) + } + res[name] = &state.Hash + default: + u160, err := util.Uint160DecodeStringLE(contractName) + if err != nil { + return nil, fmt.Errorf("invalid contract name %s", contractName) + } + res[name] = &u160 + } + } + + return res, nil +} + +func parseInterval(fromStr, toStr string, cli *rpcclient.Client) (from, to uint32, err error) { + switch { // parse from value and return result if it is relative + case len(fromStr) == 0: + return 0, 0, ErrInvalidInterval(fromStr, toStr) + case fromStr[0] == 'm': + v, err := strconv.Atoi(fromStr[1:]) + if err != nil || v <= 0 { + return 0, 0, ErrInvalidInterval(fromStr, toStr) + } + h, err := cli.GetBlockCount() + if err != nil { + return 0, 0, fmt.Errorf("latest block index unavailable: %w", err) + } + if uint32(v) >= h { + return 0, 0, fmt.Errorf("latest block is less than from value, from:%s, to:%d", fromStr, h) + } + return h - uint32(v), h, nil + default: + v, err := strconv.Atoi(fromStr) + if err != nil || v <= 0 { + return 0, 0, ErrInvalidInterval(fromStr, toStr) + } + from = uint32(v) + } + + switch { // parse to value + case len(toStr) == 0: + h, err := cli.GetBlockCount() + if err != nil { + return 0, 0, fmt.Errorf("latest block index unavailable: %w", err) + } + if h <= from { + return 0, 0, fmt.Errorf("latest block is less than from value, from:%d, to:%d", from, h) + } + return from, h, nil + case toStr[0] == 'p': + v, err := strconv.Atoi(toStr[1:]) + if err != nil || v <= 0 { + return 0, 0, ErrInvalidInterval(fromStr, toStr) + } + return from, from + uint32(v), nil + default: + v, err := strconv.Atoi(toStr) + if err != nil || v <= 0 { + return 0, 0, ErrInvalidInterval(fromStr, toStr) + } + if uint32(v) <= from { + return 0, 0, ErrInvalidInterval(fromStr, toStr) + } + return from, uint32(v), nil + } +} + +func ErrInvalidInterval(from, to string) error { + return fmt.Errorf("invalid block interval from:%s to:%s", from, to) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e9a1d22 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module git.frostfs.info/TrueCloudLab/monza + +go 1.21 + +require ( + github.com/gdamore/tcell/v2 v2.5.1 + github.com/nspcc-dev/neo-go v0.102.0 + github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 + github.com/schollz/progressbar/v3 v3.8.3 + github.com/urfave/cli/v2 v2.3.0 + go.etcd.io/bbolt v1.3.7 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/hashicorp/golang-lru v0.6.0 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/nspcc-dev/go-ordered-json v0.0.0-20220111165707-25110be27d22 // indirect + github.com/nspcc-dev/rfc6979 v0.2.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + go.uber.org/atomic v1.10.0 // indirect + golang.org/x/crypto v0.4.0 // indirect + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/term v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a31118 --- /dev/null +++ b/go.sum @@ -0,0 +1,103 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= +github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I= +github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= +github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/nspcc-dev/go-ordered-json v0.0.0-20220111165707-25110be27d22 h1:n4ZaFCKt1pQJd7PXoMJabZWK9ejjbLOVrkl/lOUmshg= +github.com/nspcc-dev/go-ordered-json v0.0.0-20220111165707-25110be27d22/go.mod h1:79bEUDEviBHJMFV6Iq6in57FEOCMcRhfQnfaf0ETA5U= +github.com/nspcc-dev/neo-go v0.102.0 h1:O2Gt4JPOWmp0c+PnPWwd2wPI74BKSwkaNCEyvyQTWJw= +github.com/nspcc-dev/neo-go v0.102.0/go.mod h1:QXxpZxJT2KedwM0Nlj8UO0/fZN2WIe4h/i03uBHKbnc= +github.com/nspcc-dev/rfc6979 v0.2.0 h1:3e1WNxrN60/6N0DW7+UYisLeZJyfqZTNOjeV/toYvOE= +github.com/nspcc-dev/rfc6979 v0.2.0/go.mod h1:exhIh1PdpDC5vQmyEsGvc4YDM/lyQp/452QxGq/UEso= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 h1:xe+mmCnDN82KhC010l3NfYlA8ZbOuzbXAzSYBa6wbMc= +github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.8.3 h1:FnLGl3ewlDUP+YdSwveXBaXs053Mem/du+wr7XSYKl8= +github.com/schollz/progressbar/v3 v3.8.3/go.mod h1:pWnVCjSBZsT2X3nx9HfRdnCDrpbevliMeoEVhStwHko= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/syndtr/goleveldb v1.0.1-0.20210305035536-64b5b1c73954 h1:xQdMZ1WLrgkkvOZ/LDQxjVxMLdby7osSh4ZEVa5sIjs= +github.com/syndtr/goleveldb v1.0.1-0.20210305035536-64b5b1c73954/go.mod h1:u2MKkTVTVJWe5D1rCvame8WqhBd88EuIwODJZ1VHCPM= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= +github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74/go.mod h1:RmMWU37GKR2s6pgrIEB4ixgpVCt/cf7dnJv3fuH1J1c= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/chain/chain.go b/internal/chain/chain.go new file mode 100644 index 0000000..b80a00b --- /dev/null +++ b/internal/chain/chain.go @@ -0,0 +1,239 @@ +package chain + +import ( + "context" + "encoding/binary" + "fmt" + "path" + "strconv" + + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/neorpc/result" + "github.com/nspcc-dev/neo-go/pkg/rpcclient" + "github.com/nspcc-dev/neo-go/pkg/util" + "go.etcd.io/bbolt" +) + +type Chain struct { + db *bbolt.DB + stateRoot bool + Client *rpcclient.Client +} + +var ( + blocksBucket = []byte("blocks") + logsBucket = []byte("logs") +) + +func Open(ctx context.Context, dir, endpoint string) (*Chain, error) { + cli, err := rpcclient.New(ctx, endpoint, rpcclient.Options{}) + if err != nil { + return nil, fmt.Errorf("rpc connection: %w", err) + } + + err = cli.Init() + if err != nil { + return nil, fmt.Errorf("rpc client initialization: %w", err) + } + + v, err := cli.GetVersion() + if err != nil { + return nil, fmt.Errorf("rpc get version: %w", err) + } + + dbPath := path.Join(dir, strconv.Itoa(int(v.Protocol.Network))+".db") + + db, err := bbolt.Open(dbPath, 0600, nil) + if err != nil { + return nil, fmt.Errorf("database [%s] init: %w", dbPath, err) + } + + return &Chain{db, v.Protocol.StateRootInHeader, cli}, nil +} + +func (d *Chain) Block(i uint32) (res *block.Block, err error) { + cached, err := d.block(i) + if err != nil { + return nil, err + } + + if cached != nil { + return cached, nil + } + + metaBlock, err := d.Client.GetBlockByIndexVerbose(i) + if err != nil { + return nil, fmt.Errorf("block %d fetch: %w", i, err) + } + + return &metaBlock.Block, d.addBlock(&metaBlock.Block) +} + +func (d *Chain) BlockByHash(h util.Uint256) (res *block.Block, err error) { + rev := h.Reverse() + metaBlock, err := d.Client.GetBlockByHashVerbose(rev) + if err != nil { + return nil, fmt.Errorf("block %s fetch: %w", h.StringLE(), err) + } + + return &metaBlock.Block, d.addBlock(&metaBlock.Block) +} + +func (d *Chain) block(i uint32) (res *block.Block, err error) { + err = d.db.View(func(tx *bbolt.Tx) error { + key := make([]byte, 4) + binary.LittleEndian.PutUint32(key, i) + + bkt := tx.Bucket(blocksBucket) + if bkt == nil { + return nil + } + + data := bkt.Get(key) + if len(data) == 0 { + return nil + } + + res = block.New(d.stateRoot) + r := io.NewBinReaderFromBuf(data) + res.DecodeBinary(r) + + return r.Err + }) + if err != nil { + return nil, fmt.Errorf("cannot read block %d from cache: %w", i, err) + } + + return res, nil +} + +func (d *Chain) addBlock(block *block.Block) error { + err := d.db.Batch(func(tx *bbolt.Tx) error { + key := make([]byte, 4) + binary.LittleEndian.PutUint32(key, block.Index) + + w := io.NewBufBinWriter() + block.EncodeBinary(w.BinWriter) + if w.Err != nil { + return w.Err + } + + bkt, err := tx.CreateBucketIfNotExists(blocksBucket) + if err != nil { + return err + } + + return bkt.Put(key, w.Bytes()) + }) + if err != nil { + return fmt.Errorf("cannot add block %d to cache: %w", block.Index, err) + } + + return nil +} + +func (d *Chain) ApplicationLog(txHash util.Uint256) (*result.ApplicationLog, error) { + cached, err := d.applicationLog(txHash) + if err != nil { + return nil, err + } + + if cached != nil { + return cached, nil + } + + appLog, err := d.Client.GetApplicationLog(txHash, nil) + if err != nil { + return nil, fmt.Errorf("app log of tx %s fetch: %w", txHash.StringLE(), err) + } + + return appLog, d.addApplicationLog(txHash, appLog) +} + +func (d *Chain) applicationLog(txHash util.Uint256) (res *result.ApplicationLog, err error) { + err = d.db.View(func(tx *bbolt.Tx) error { + bkt := tx.Bucket(logsBucket) + if bkt == nil { + return nil + } + + data := bkt.Get(txHash.BytesLE()) + if len(data) == 0 { + return nil + } + + res = new(result.ApplicationLog) + return res.UnmarshalJSON(bkt.Get(txHash.BytesLE())) + }) + if err != nil { + return nil, fmt.Errorf("cannot read tx %s from cache: %w", txHash.StringLE(), err) + } + + return res, nil +} + +func (d *Chain) addApplicationLog(txHash util.Uint256, appLog *result.ApplicationLog) error { + err := d.db.Batch(func(tx *bbolt.Tx) error { + val, err := appLog.MarshalJSON() + if err != nil { + return err + } + + bkt, err := tx.CreateBucketIfNotExists(logsBucket) + if err != nil { + return err + } + + return bkt.Put(txHash.BytesLE(), val) + }) + if err != nil { + return fmt.Errorf("cannot add tx %s to cache: %w", txHash.StringLE(), err) + } + + return nil +} + +func (d *Chain) Notifications(txHash util.Uint256) ([]state.NotificationEvent, error) { + appLog, err := d.ApplicationLog(txHash) + if err != nil { + return nil, err + } + + res := make([]state.NotificationEvent, 0) + for _, execution := range appLog.Executions { + res = append(res, execution.Events...) + } + + return res, nil +} + +func (d *Chain) AllNotifications(b *block.Block) ([]state.NotificationEvent, error) { + res := make([]state.NotificationEvent, 0) + + appLog, err := d.ApplicationLog(b.Hash()) + if err != nil { + return nil, err + } + + for _, execution := range appLog.Executions { + res = append(res, execution.Events...) + } + + for _, tx := range b.Transactions { + appLog, err = d.ApplicationLog(tx.Hash()) + if err != nil { + return nil, err + } + for _, execution := range appLog.Executions { + res = append(res, execution.Events...) + } + } + + return res, nil +} + +func (d *Chain) Close() { + _ = d.db.Close() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bc6b2f5 --- /dev/null +++ b/main.go @@ -0,0 +1,273 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/signal" + "path" + "sync" + "time" + + "git.frostfs.info/TrueCloudLab/monza/internal/chain" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/schollz/progressbar/v3" + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "monza", + Usage: "monitor notification events in N3 compatible chains", + Commands: []*cli.Command{ + { + Name: "run", + Usage: "look up over subset of blocks to find notifications", + UsageText: "monza run -r [endpoint] --from 101000 --to p1000 -n \"Transfer:gas\" -n \"newEpoch:*\"", + Action: monza, + Flags: []cli.Flag{ + endpointFlag, + fromFlag, + toFlag, + notificationFlag, + cacheFlag, + workersFlag, + disableProgressBarFlag, + }, + }, + { + Name: "stutter", + Usage: "find stuttered blocks in subset", + UsageText: "monza stutter -r [endpoint] --from 101000 --to p1000 --threshold 20s", + Action: stutter, + Flags: []cli.Flag{ + endpointFlag, + fromFlag, + toFlag, + stutterThresholdFlag, + cacheFlag, + workersFlag, + disableProgressBarFlag, + }, + }, + { + Name: "explore", + Usage: "explore stuttered blocks in subset", + UsageText: "monza explore -r [endpoint]", + Action: explorer, + Flags: []cli.Flag{ + endpointFlag, + cacheFlag, + }, + }, + }, + } + + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } + +} + +func monza(c *cli.Context) (err error) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + + // parse blockchain info + cacheDir := c.String(cacheFlagKey) + if len(cacheDir) == 0 { + cacheDir, err = defaultConfigDir() + if err != nil { + return err + } + } + + blockchain, err := chain.Open(ctx, cacheDir, c.String(endpointFlagKey)) + if err != nil { + return fmt.Errorf("cannot initialize remote blockchain client: %w", err) + } + defer func() { + blockchain.Close() + cancel() + }() + + // parse block indices + from, to, err := parseInterval(c.String(fromFlagKey), c.String(toFlagKey), blockchain.Client) + if err != nil { + return err + } + + // parse notifications + notifications, err := parseNotifications(c.StringSlice(notificationFlagKey), blockchain.Client) + if err != nil { + return err + } + + // start monza + return run(ctx, ¶ms{ + from: from, + to: to, + blockchain: blockchain, + notifications: notifications, + workers: int(c.Uint64(workersFlagKey)), + disableBar: c.Bool(disableProgressBarFlagKey), + }) +} + +type params struct { + from, to uint32 + blockchain *chain.Chain + notifications map[string]*util.Uint160 + workers int + disableBar bool +} + +func run(ctx context.Context, p *params) error { + err := cacheBlocks(ctx, p) + if err != nil { + return err + } + + for i := p.from; i < p.to; i++ { + b, err := p.blockchain.Block(i) + if err != nil { + return fmt.Errorf("cannot fetch block %d: %w", i, err) + } + + notifications, err := p.blockchain.AllNotifications(b) + if err != nil { + return fmt.Errorf("cannot fetch notifications from block %d: %w", i, err) + } + + for _, ev := range notifications { + contract, ok := p.notifications[ev.Name] + if !ok { + continue + } + + if contract != nil && !contract.Equals(ev.ScriptHash) { + continue + } + + switch ev.Name { + case "Transfer": + PrintTransfer(b, ev) + case "NewEpoch": + PrintNewEpoch(b, ev) + case "AddPeerSuccess": + PrintAddPeerSuccess(b, ev) + case "UpdateState": + PrintUpdateState(b, ev) + default: + PrintEvent(b, ev, "") + } + } + } + + return nil +} + +func cacheBlocks(ctx context.Context, p *params) error { + if p.workers <= 0 { + return fmt.Errorf("invalid amount of workers %d", p.workers) + } + + var bar *progressbar.ProgressBar + if !p.disableBar { + bar = progressbar.NewOptions(int(p.to-p.from), + progressbar.OptionSetDescription("syncing"), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionSetWidth(10), + progressbar.OptionThrottle(65*time.Millisecond), + progressbar.OptionShowCount(), + progressbar.OptionShowIts(), + progressbar.OptionSetItsString("blocks"), + progressbar.OptionOnCompletion(func() { + _, _ = fmt.Fprint(os.Stderr, "\n") + }), + progressbar.OptionSpinnerType(14), + progressbar.OptionSetWidth(50), + progressbar.OptionSetTheme(progressbar.Theme{ + Saucer: "#", + SaucerHead: "#", + SaucerPadding: " ", + BarStart: "[", + BarEnd: "]", + }), + ) + } + + jobCh := make(chan uint32) + errCh := make(chan error) + wgCh := make(chan struct{}) + + wg := new(sync.WaitGroup) + + for i := 0; i < p.workers; i++ { + go func(ctx context.Context, ch <-chan uint32, out chan<- error) { + for { + select { + case <-ctx.Done(): + return + case block, ok := <-ch: + wg.Add(1) + if !ok { + return + } + b, err := p.blockchain.Block(block) + if err != nil { + out <- err + return + } + _, err = p.blockchain.AllNotifications(b) + if err != nil { + out <- err + return + } + if bar != nil { + _ = bar.Add(1) + } + wg.Done() + } + } + }(ctx, jobCh, errCh) + } + + for i := p.from; i < p.to; i++ { + select { + case <-ctx.Done(): + return errors.New("interrupted") + case err := <-errCh: + return err + case jobCh <- i: + } + } + + go func() { + wg.Wait() + close(wgCh) + }() + + select { + case <-ctx.Done(): + return errors.New("interrupted") + case err := <-errCh: + return err + case <-wgCh: + return nil + } +} + +func defaultConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("cannot determine home dir for default config path: %s", err) + } + + p := path.Join(home, ".config") + p = path.Join(p, "monza") + + return p, os.MkdirAll(p, os.ModePerm) +} diff --git a/stutter.go b/stutter.go new file mode 100644 index 0000000..8d0585f --- /dev/null +++ b/stutter.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "time" + + "git.frostfs.info/TrueCloudLab/monza/internal/chain" + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/urfave/cli/v2" +) + +func stutter(c *cli.Context) (err error) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + + // parse blockchain info + cacheDir := c.String(cacheFlagKey) + if len(cacheDir) == 0 { + cacheDir, err = defaultConfigDir() + if err != nil { + return err + } + } + + blockchain, err := chain.Open(ctx, cacheDir, c.String(endpointFlagKey)) + if err != nil { + return fmt.Errorf("cannot initialize remote blockchain client: %w", err) + } + defer func() { + blockchain.Close() + cancel() + }() + + // parse block indices + from, to, err := parseInterval(c.String(fromFlagKey), c.String(toFlagKey), blockchain.Client) + if err != nil { + return err + } + + threshold := c.Duration(stutterThresholdFlagKey) + + // need at least two blocks + if to-from < 2 { + return errors.New("range must contain at least two blocks") + } + + // fetch blocks + err = cacheBlocks(ctx, ¶ms{ + from: from, + to: to, + blockchain: blockchain, + workers: int(c.Uint64(workersFlagKey)), + disableBar: c.Bool(disableProgressBarFlagKey), + }) + if err != nil { + return err + } + + // process blocks one by one + var ( + prev, curr *block.Block + prevTS, currTS time.Time + lastStutterBlock uint32 + ) + + for i := from; i < to; i++ { + b, err := blockchain.Block(i) + if err != nil { + return fmt.Errorf("cannot fetch block %d: %w", i, err) + } + + prev, prevTS = curr, currTS + curr = b + currTS = time.Unix(int64(b.Timestamp/1e3), 0) + if prev == nil { // first block case + continue + } + + blockDelta := currTS.Sub(prevTS) + if blockDelta <= threshold { + continue + } + + skippedBlocks := prev.Index - lastStutterBlock + if lastStutterBlock > 0 && skippedBlocks > 1 { + fmt.Printf("-- skipped %d blocks --\n", skippedBlocks-1) + } + + PrintBlock(prev, "") + PrintBlock(curr, fmt.Sprintf("<- stutter for %s", blockDelta)) + lastStutterBlock = curr.Index + } + + return nil +}