diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index e7678228..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.decisions/2-Release-Cycle.md b/.decisions/2-Release-Cycle.md new file mode 100644 index 00000000..f741ee25 --- /dev/null +++ b/.decisions/2-Release-Cycle.md @@ -0,0 +1,19 @@ +# โ„น๏ธ Overview + +The release cycle is largely based on the internal Algorand Foundation's engineering compass. +It has been tailored to work with a cli based tool with binary artifacts + +## โœ… Decisions + +- **SHOULD** use conventional commits for consistency +- **SHOULD** use automated release tools like semantic-release +- **SHOULD** build for arm/amd64 on darwin and linux +- **SHOULD** have checks on code quality and regressions +- **SHOULD** present a manual page and generated reference material +- **SHOULD** provide handcrafted guides and documentation + +## ๐Ÿ”จ Deliverables + +- automated CI/CD release actions +- installer features for consumers +- marketing landing page with documentation site \ No newline at end of file diff --git a/.decisions/3-Node-Management.md b/.decisions/3-Node-Management.md new file mode 100644 index 00000000..7542edc9 --- /dev/null +++ b/.decisions/3-Node-Management.md @@ -0,0 +1,17 @@ +# โ„น๏ธ Overview + +Node Management has many aspects which this decision makes concrete. + +## โœ… Decisions + +- **SHOULD** include install/upgrade commands +- **SHOULD** include start/stop commands +- **SHOULD** include catchup commands +- **SHOULD** include bootstrap command + +## ๐Ÿ”จ Deliverables + +- Use package managers for installation and upgrades (brew, dnf, apt-get) +- Use native supervisors for algod orchestration (launchd, systemd) +- Bootstrap concept which ties several components together (install, start, fast-catchup, launch TUI) +- Limited amount of configurations for the initial release \ No newline at end of file diff --git a/.decisions/README.md b/.decisions/README.md index f13882e5..173ebd1d 100644 --- a/.decisions/README.md +++ b/.decisions/README.md @@ -1,3 +1,5 @@ # Decisions for this Project -- [1. GoLang/Charm TUI](1-GoLang-Charm.md) \ No newline at end of file +- [1. GoLang/Charm TUI](1-GoLang-Charm.md) +- [2. Release Cycle](2-Release-Cycle.md) +- [3. Node Management](3-Node-Management.md) \ No newline at end of file diff --git a/.gitignore b/.gitignore index a08a225b..ca362f52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store nodekit coverage bin diff --git a/.tapes/README.md b/.tapes/README.md new file mode 100644 index 00000000..c1ff62b9 --- /dev/null +++ b/.tapes/README.md @@ -0,0 +1,37 @@ +# Overview + +Includes various [vhs](https://github.com/charmbracelet/vhs) tapes for the project. +Useful for creating consistent demos and guides when the TUI updates + + +## Get Started + +Install vhs + +```bash +go install github.com/charmbracelet/vhs@latest +``` + +Copy the default `tui.tape` and name it appropriately + +```bash +cp ./tui.tape ./my-demo.tape +``` + +Edit the tape with your favorite editor. +Then you can run the vhs tape + +(Make sure to update the output file) + +```bash +vhs ./my-demo.tape +``` + +### Theme + +Example theme that uses some of the official Algorand Foundation brand guides + +``` +Set Theme { "name": "Whimsy", "black": "#2D2DFI", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#001324", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } +``` + diff --git a/.tapes/tui.tape b/.tapes/tui.tape new file mode 100644 index 00000000..9f8f94e2 --- /dev/null +++ b/.tapes/tui.tape @@ -0,0 +1,25 @@ +Output ../assets/tapes/tui.gif + +Require nodekit + +Set Framerate 30 +Set Margin 1 + +Set Theme { "name": "Whimsy", "black": "#2D2DFI", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#001324", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } + +Set Shell "bash" +Set FontSize 10 +Set Width 1280 +Set Height 640 + +Type "nodekit" Sleep 500ms Enter + +Sleep 3s Enter + +Sleep 500ms Down Sleep 1s Enter + +Sleep 1s Type "o" + +Sleep 5s + +Ctrl+C diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 577ae06b..c2efa1bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,28 +22,8 @@ Build the project make build ``` -Optionally, run a sandboxed participation node - - -```bash -docker compose up -``` - -Create a configuration file for the participation node in the root directory of the project (.nodekit.yaml) - -```yaml -algod-endpoint: http://localhost:8080 -algod-token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -``` - -Launch the TUI - - -> [!NOTE] -> If you skipped the docker container or config file, try running `./bin/nodekit` standalone, -> which will detect your algorand data directory from the `ALGORAND_DATA` environment variable that works for `goal`. -> Otherwise, provide the `--algod-endpoint` and `--algod-token` arguments so that it can find your node. -> Note that nodekit requires the admin algod token. +Launch the TUI. +See the [full documentation](https://nodekit.run/guides/getting-started/) for a complete guide ```bash ./bin/nodekit @@ -52,7 +32,7 @@ Launch the TUI # ๐Ÿ“‚ Folder Structure ```bash -โ”œโ”€โ”€ api # Generated API Client +โ”œโ”€โ”€ api # Generated API Client and Hand Crafted HTTPInterface โ”œโ”€โ”€ cmd # Command Controller โ”œโ”€โ”€ internal # Data Models/Fetch Wrappers โ””โ”€โ”€ ui # BubbleTea Interfaces @@ -142,3 +122,8 @@ The full command for reference ```bash oapi-codegen -config generate.yaml https://raw.githubusercontent.com/algorand/go-algorand/v3.26.0-stable/daemon/algod/api/algod.oas3.yml ``` + +# Submitting Changes + +This project follows [GitHub flow](https://githubflow.github.io/). +Create a fork and submit changes directly to the `main` branch. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..202e36eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Algorand Foundation + +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/assets/tapes/tui.gif b/assets/tapes/tui.gif new file mode 100644 index 00000000..2518ed55 Binary files /dev/null and b/assets/tapes/tui.gif differ diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 2ba4120b..d73f9d53 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -66,7 +66,7 @@ var bootstrapCmd = &cobra.Command{ if err != nil { log.Fatal(err) } - err = runTUI(RootCmd, dir) + err = runTUI(RootCmd, dir, false) if err != nil { log.Fatal(err) } @@ -176,6 +176,6 @@ var bootstrapCmd = &cobra.Command{ } - return runTUI(RootCmd, dataDir) + return runTUI(RootCmd, dataDir, false) }, } diff --git a/cmd/root.go b/cmd/root.go index da86caa6..d1432fc4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,9 @@ var ( NeedsUpgrade = false + // whether the user forbids incentive eligibility fees to be set + IncentivesDisabled = false + // algodEndpoint defines the URI address of the Algorand node, including the protocol (http/https), for client communication. algodData string @@ -53,7 +56,7 @@ var ( }, Run: func(cmd *cobra.Command, args []string) { log.SetOutput(cmd.OutOrStdout()) - err := runTUI(cmd, algodData) + err := runTUI(cmd, algodData, IncentivesDisabled) if err != nil { log.Fatal(err) } @@ -90,6 +93,7 @@ func NeedsToBeStopped(cmd *cobra.Command, args []string) { // init initializes the application, setting up logging, commands, and version information. func init() { log.SetReportTimestamp(false) + RootCmd.Flags().BoolVarP(&IncentivesDisabled, "no-incentives", "n", false, style.LightBlue("Disable setting incentive eligibility fees")) RootCmd.SetVersionTemplate(fmt.Sprintf("nodekit-%s-%s@{{.Version}}\n", runtime.GOARCH, runtime.GOOS)) // Add Commands if runtime.GOOS != "windows" { @@ -112,7 +116,7 @@ func Execute(version string, needsUpgrade bool) error { return RootCmd.Execute() } -func runTUI(cmd *cobra.Command, dataDir string) error { +func runTUI(cmd *cobra.Command, dataDir string, incentivesFlag bool) error { if cmd == nil { return fmt.Errorf("cmd is nil") } @@ -124,7 +128,7 @@ func runTUI(cmd *cobra.Command, dataDir string) error { cobra.CheckErr(err) // Fetch the state and handle any creation errors - state, stateResponse, err := algod.NewStateModel(ctx, client, httpPkg) + state, stateResponse, err := algod.NewStateModel(ctx, client, httpPkg, incentivesFlag) utils.WithInvalidResponsesExplanations(err, stateResponse, cmd.UsageString()) cobra.CheckErr(err) diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index e41e6987..0261c708 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "github.com/algorandfoundation/nodekit/api" "github.com/algorandfoundation/nodekit/cmd/utils/explanations" "github.com/algorandfoundation/nodekit/internal/algod" @@ -20,8 +21,7 @@ func WithInvalidResponsesExplanations(err error, response api.ResponseInterface, } if response.StatusCode() > 300 { log.Fatal( - style.Red.Render("failed to get status: error code %d")+"\n\n"+explanations.TokenNotAdmin+"\n"+postFix, - response.StatusCode()) + style.Red.Render(fmt.Sprintf("failed to get status: error code %d", response.StatusCode())) + "\n\n" + explanations.TokenNotAdmin + "\n" + postFix) } } diff --git a/internal/algod/mac/mac.go b/internal/algod/mac/mac.go index 3286ca15..09909552 100644 --- a/internal/algod/mac/mac.go +++ b/internal/algod/mac/mac.go @@ -113,11 +113,19 @@ func Upgrade(force bool) error { if !system.CmdExists("brew") { return errors.New("homebrew is not installed") } - - return system.RunAll(system.CmdsList{ + err := system.RunAll(system.CmdsList{ {"brew", "--prefix", "algorand", "--installed"}, + {"brew", "update"}, {"brew", "upgrade", "algorand", "--formula"}, }) + if err != nil { + return err + } + err = Stop(false) + if err != nil { + return err + } + return Start(false) } // Start algorand with launchd diff --git a/internal/algod/participation/participation.go b/internal/algod/participation/participation.go index da50a47e..c7ba3402 100644 --- a/internal/algod/participation/participation.go +++ b/internal/algod/participation/participation.go @@ -167,7 +167,7 @@ func GetOnlineShortLink(http api.HttpPkgInterface, part OnlineShortLinkBody) (Sh if err != nil { return response, err } - res, err := http.Post("http://b.nodekit.run/online", "applicaiton/json", bytes.NewReader(data)) + res, err := http.Post("http://b.nodekit.run/online", "application/json", bytes.NewReader(data)) if err != nil { return response, err } @@ -219,6 +219,10 @@ func GetOfflineShortLink(http api.HttpPkgInterface, offline OfflineShortLinkBody // ToShortLink generates a shortened URL string using the unique // identifier from the provided ShortLinkResponse. -func ToShortLink(link ShortLinkResponse) string { - return fmt.Sprintf("https://b.nodekit.run/%s", link.Id) +func ToShortLink(link ShortLinkResponse, incentiveEligibleFee bool) string { + suffix := "" + if incentiveEligibleFee { + suffix = "i" + } + return fmt.Sprintf("https://b.nodekit.run/%s%s", link.Id, suffix) } diff --git a/internal/algod/state.go b/internal/algod/state.go index ac4bc8c9..e0ffab00 100644 --- a/internal/algod/state.go +++ b/internal/algod/state.go @@ -38,6 +38,9 @@ type StateModel struct { // TODO: handle contexts instead of adding it to state (skill-issue zero) Watching bool + // Whether user has disabled automatically applying incentive eligibility fees + IncentivesDisabled bool + // Client provides an interface for interacting with API endpoints, // enabling various node operations and data retrieval. Client api.ClientWithResponsesInterface @@ -53,7 +56,7 @@ type StateModel struct { // NewStateModel initializes and returns a new StateModel instance // along with an API response and potential error. -func NewStateModel(ctx context.Context, client api.ClientWithResponsesInterface, httpPkg api.HttpPkgInterface) (*StateModel, api.ResponseInterface, error) { +func NewStateModel(ctx context.Context, client api.ClientWithResponsesInterface, httpPkg api.HttpPkgInterface, incentivesDisabled bool) (*StateModel, api.ResponseInterface, error) { // Preload the node status status, response, err := NewStatus(ctx, client, httpPkg) if err != nil { @@ -79,6 +82,8 @@ func NewStateModel(ctx context.Context, client api.ClientWithResponsesInterface, Client: client, HttpPkg: httpPkg, Context: ctx, + + IncentivesDisabled: incentivesDisabled, }, partkeysResponse, nil } @@ -116,6 +121,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co if !s.Watching { break } + // Abort on Fast-Catchup if s.Status.State == FastCatchupState { // Update current render @@ -132,6 +138,9 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co cb(s, nil) continue } + // Fetch Keys + s.UpdateKeys(ctx, t) + cb(s, nil) // Wait for the next block s.Status, _, err = s.Status.Wait(ctx) @@ -140,9 +149,6 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co continue } - // Fetch Keys - s.UpdateKeys(ctx, t) - if s.Status.State == SyncingState { cb(s, nil) continue @@ -176,18 +182,18 @@ func (s *StateModel) UpdateKeys(ctx context.Context, t system.Time) { if err == nil { s.Admin = true s.Accounts = ParticipationKeysToAccounts(s.ParticipationKeys) + + // For each account, update the data from the RPC endpoint for _, acct := range s.Accounts { - // For each account, update the data from the RPC endpoint - if s.Status.State == StableState { - // Skip eon errors - rpcAcct, err := GetAccount(s.Client, acct.Address) - if err != nil { - continue - } - - s.Accounts[acct.Address] = s.Accounts[acct.Address].Merge(rpcAcct) - s.Accounts[acct.Address] = s.Accounts[acct.Address].UpdateExpiredTime(t, s.ParticipationKeys, int(s.Status.LastRound), s.Metrics.RoundTime) + // Skip eon errors + rpcAcct, err := GetAccount(s.Client, acct.Address) + if err != nil { + continue } + + s.Accounts[acct.Address] = s.Accounts[acct.Address].Merge(rpcAcct) + s.Accounts[acct.Address] = s.Accounts[acct.Address].UpdateExpiredTime(t, s.ParticipationKeys, int(s.Status.LastRound), s.Metrics.RoundTime) } + } } diff --git a/internal/algod/status.go b/internal/algod/status.go index a6ac632d..0a0ea520 100644 --- a/internal/algod/status.go +++ b/internal/algod/status.go @@ -48,6 +48,9 @@ type Status struct { // NeedsUpdate indicates whether the system requires an update based on the current version and available release data. NeedsUpdate bool `json:"needsUpdate"` + // LastProtocolVersion represents the most recent round protocol version. + LastProtocolVersion string `json:"lastProtocolVersion"` + // LastRound represents the most recent round number recorded by the system or client. LastRound uint64 `json:"lastRound"` @@ -106,6 +109,9 @@ func (s Status) Update(status Status) Status { if s.LastRound != status.LastRound { s.LastRound = status.LastRound } + if s.LastProtocolVersion != status.LastProtocolVersion { + s.LastProtocolVersion = status.LastProtocolVersion + } return s } @@ -127,6 +133,7 @@ func (s Status) Wait(ctx context.Context) (Status, api.ResponseInterface, error) // Merge updates the current Status with data from a given StatusLike instance and adjusts fields based on defined conditions. func (s Status) Merge(res api.StatusLike) Status { s.LastRound = uint64(res.LastRound) + s.LastProtocolVersion = res.LastVersion catchpoint := res.Catchpoint if catchpoint != nil && *catchpoint != "" { s.State = FastCatchupState diff --git a/internal/system/cmds.go b/internal/system/cmds.go index fba2afd1..b13b566b 100644 --- a/internal/system/cmds.go +++ b/internal/system/cmds.go @@ -1,6 +1,7 @@ package system import ( + "errors" "fmt" "github.com/algorandfoundation/nodekit/ui/style" "github.com/charmbracelet/log" @@ -11,9 +12,6 @@ import ( "sync" ) -// CmdFailedErrorMsg is a formatted error message used to detail command failures, including output and the associated error. -const CmdFailedErrorMsg = "command failed: %s output: %s error: %v" - // IsSudo checks if the process is running with root privileges by verifying the effective user ID is 0. func IsSudo() bool { return os.Geteuid() == 0 @@ -60,9 +58,24 @@ func RunAll(list CmdsList) error { log.Debug(fmt.Sprintf("%s: %s", style.Green.Render("Running"), strings.Join(args, " "))) cmd := exec.Command(args[0], args[1:]...) output, err := cmd.CombinedOutput() + if err != nil { - log.Error(fmt.Sprintf("%s: %s", style.Red.Render("Failed"), strings.Join(args, " "))) - return fmt.Errorf(CmdFailedErrorMsg, strings.Join(args, " "), output, err) + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // Get Stderr when possible + if exitErr.Stderr != nil { + log.Error(exitErr.Stderr) + } else { + // Report the output + log.Error(strings.TrimSuffix(string(output), "\n")) + } + + } else { + // Log the regular errors + log.Error(err) + } + // Alert the User + return fmt.Errorf("%s: %s", style.Red.Render("Failed"), strings.Join(args, " ")) } } diff --git a/main.go b/main.go index e6692df6..b4235688 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,7 @@ func main() { var needsUpgrade = false resp, err := api.GetNodeKitReleaseWithResponse(new(api.HttpPkg)) if err == nil && resp.ResponseCode >= 200 && resp.ResponseCode < 300 { - if resp.JSON200 != version { + if version != "dev" && resp.JSON200 != version { needsUpgrade = true // Warn on all commands but version if len(os.Args) > 1 && os.Args[1] != "--version" { diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..c461f4ec --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,7 @@ +# Overview + +Collection of scripts and utilities used in this project. + +# Documentation + +Documentation generator is based on CobraDoc \ No newline at end of file diff --git a/scripts/wallet.json b/scripts/wallet.json new file mode 100644 index 00000000..fab5f2fb --- /dev/null +++ b/scripts/wallet.json @@ -0,0 +1,5 @@ +{ + "mnemonic": "artefact exist coil life turtle edge edge inside punch glance recycle teach melody diet method pause slam dumb race interest amused side learn able heavy", + "address": "TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU", + "private_key": "Z/CTWhR4dRnJKHVurdhn6U3F9oRxoVj+0GBbF4Qf20+dEDUfQvsofIx/+wDKRImKmq12WLh+4BQeSUIdceRfaQ==" +} \ No newline at end of file diff --git a/scripts/wallet.mjs b/scripts/wallet.mjs deleted file mode 100644 index b26f5384..00000000 --- a/scripts/wallet.mjs +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node - -import { - Algodv2, - makeKeyRegistrationTxnWithSuggestedParamsFromObject, - mnemonicToSecretKey, - waitForConfirmation -} from 'algosdk' -const key = { - 'mnemonic': 'artefact exist coil life turtle edge edge inside punch glance recycle teach melody diet method pause slam dumb race interest amused side learn able heavy', - 'address': 'TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU', - 'private_key': 'Z/CTWhR4dRnJKHVurdhn6U3F9oRxoVj+0GBbF4Qf20+dEDUfQvsofIx/+wDKRImKmq12WLh+4BQeSUIdceRfaQ==' -} -const account = mnemonicToSecretKey(key.mnemonic) -console.log(account) - - -const keys = await fetch('http://localhost:8081/v2/participation', { - headers: { - "X-Algo-API-Token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - } -}).then(r => r.json()) - -const partkey = keys.filter((k)=>k.address === key.address)[0] -console.log(partkey) - -const client = new Algodv2( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "http://localhost", - 8081 -) - -const params = await client.getTransactionParams().do() - - -// sets up keys for 100000 rounds -const numRounds = 1e5; - -// dilution default is sqrt num rounds -const keyDilution = BigInt(Math.floor(numRounds ** 0.5)); - -const txn = makeKeyRegistrationTxnWithSuggestedParamsFromObject({ - sender: key.address, - voteKey: Buffer.from(partkey.key['vote-participation-key'], "base64"), - selectionKey: Buffer.from(partkey.key['selection-participation-key'], "base64"), - stateProofKey: Buffer.from(partkey.key['state-proof-key'], "base64"), - voteFirst: partkey.key['vote-first-valid'], - voteLast: partkey.key['vote-last-valid'], - voteKeyDilution: partkey.key['vote-key-dilution'], - suggestedParams: params, - } -) - -const signtxn = txn.signTxn(account.sk) - -const { txId } = await client.sendRawTransaction(signtxn).do(); -const result = await waitForConfirmation(client, txId, 40); -console.log(txn) diff --git a/ui/bootstrap/model.go b/ui/bootstrap/model.go index 11c9537f..c5577f94 100644 --- a/ui/bootstrap/model.go +++ b/ui/bootstrap/model.go @@ -95,6 +95,13 @@ func (m Model) View() string { case CatchupQuestion: str = CatchupQuestionMsg } - msg, _ := glamour.Render(str, "dark") + var msg string + r, err := glamour.NewTermRenderer(glamour.WithAutoStyle()) + if err != nil { + // Fallback to dark mode + msg, _ = glamour.Render(str, "dark") + } else { + msg, _ = r.Render(str) + } return msg } diff --git a/ui/modal/testdata/Test_Snapshot/InfoModal.golden b/ui/modal/testdata/Test_Snapshot/InfoModal.golden index 3e28416a..7e5459fc 100644 --- a/ui/modal/testdata/Test_Snapshot/InfoModal.golden +++ b/ui/modal/testdata/Test_Snapshot/InfoModal.golden @@ -31,20 +31,20 @@ - โ•ญโ”€โ”€Key Informationโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - โ”‚ โ”‚ - โ”‚ Account: ABC โ”‚ - โ”‚ Participation ID: 123 โ”‚ - โ”‚ โ”‚ - โ”‚ Vote Key: VEVTVEtFWQ โ”‚ - โ”‚ Selection Key: VEVTVEtFWQ โ”‚ - โ”‚ State Proof Key: VEVTVEtFWQ โ”‚ - โ”‚ โ”‚ - โ”‚ Vote First Valid: 0 โ”‚ - โ”‚ Vote Last Valid: 30000 โ”‚ - โ”‚ Vote Key Dilution: 100 โ”‚ - โ”‚ โ”‚ - โ•ฐโ”€โ”€( (d)elete | (o)nline )โ”€โ”€โ”€โ”€โ•ฏ + โ•ญโ”€โ”€Key Informationโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ โ”‚ + โ”‚ Account: ABC โ”‚ + โ”‚ Participation ID: 123 โ”‚ + โ”‚ โ”‚ + โ”‚ Vote Key: VEVTVEtFWQ== โ”‚ + โ”‚ Selection Key: VEVTVEtFWQ== โ”‚ + โ”‚ State Proof Key: VEVTVEtFWQ== โ”‚ + โ”‚ โ”‚ + โ”‚ Vote First Valid: 0 โ”‚ + โ”‚ Vote Last Valid: 30000 โ”‚ + โ”‚ Vote Key Dilution: 100 โ”‚ + โ”‚ โ”‚ + โ•ฐโ”€โ”€โ”€โ”€( (d)elete | (o)nline )โ”€โ”€โ”€โ”€โ•ฏ diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index fa9b2073..44a95a14 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -92,9 +92,9 @@ func (m ViewModel) View() string { } account := style.Cyan.Render("Account: ") + m.Participation.Address id := style.Cyan.Render("Participation ID: ") + m.Participation.Id - selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.SelectionParticipationKey[:]) - vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.VoteParticipationKey[:]) - stateProof := style.Yellow.Render("State Proof Key: ") + *utils.UrlEncodeBytesPtrOrNil(*m.Participation.Key.StateProofKey) + selection := style.Yellow.Render("Selection Key: ") + *utils.Base64EncodeBytesPtrOrNil(m.Participation.Key.SelectionParticipationKey[:]) + vote := style.Yellow.Render("Vote Key: ") + *utils.Base64EncodeBytesPtrOrNil(m.Participation.Key.VoteParticipationKey[:]) + stateProof := style.Yellow.Render("State Proof Key: ") + *utils.Base64EncodeBytesPtrOrNil(*m.Participation.Key.StateProofKey) voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.Participation.Key.VoteFirstValid) voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.Participation.Key.VoteLastValid) voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.Participation.Key.VoteKeyDilution) diff --git a/ui/modals/info/testdata/Test_Snapshot/Visible.golden b/ui/modals/info/testdata/Test_Snapshot/Visible.golden index e8bc0e8d..9ccb5058 100644 --- a/ui/modals/info/testdata/Test_Snapshot/Visible.golden +++ b/ui/modals/info/testdata/Test_Snapshot/Visible.golden @@ -1,12 +1,12 @@ - -Account: ABC -Participation ID: 123 - -Vote Key: VEVTVEtFWQ -Selection Key: VEVTVEtFWQ -State Proof Key: VEVTVEtFWQ - -Vote First Valid: 0 -Vote Last Valid: 30000 -Vote Key Dilution: 100 - \ No newline at end of file + +Account: ABC +Participation ID: 123 + +Vote Key: VEVTVEtFWQ== +Selection Key: VEVTVEtFWQ== +State Proof Key: VEVTVEtFWQ== + +Vote First Valid: 0 +Vote Last Valid: 30000 +Vote Key Dilution: 100 + \ No newline at end of file diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go index ac28515a..12cbaa53 100644 --- a/ui/modals/transaction/controller.go +++ b/ui/modals/transaction/controller.go @@ -54,6 +54,21 @@ func (m *ViewModel) Account() *algod.Account { return nil } + +func (m *ViewModel) IsIncentiveProtocol() bool { + return m.State.Status.LastProtocolVersion == "https://github.com/algorandfoundation/specs/tree/236dcc18c9c507d794813ab768e467ea42d1b4d9" +} + +// Whether the 2A incentive fee should be added +func (m *ViewModel) ShouldAddIncentivesFee() bool { + // conditions for 2A fee: + // 1) incentives allowed by user: command line flag to disable incentives has not been passed + // 2) online keyreg + // 3) protocol supports incentives + // 4) account is not already incentives eligible + return m.State != nil && !m.State.IncentivesDisabled && !m.Active && m.IsIncentiveProtocol() && !m.Account().IncentiveEligible +} + func (m *ViewModel) UpdateState() { if m.Participation == nil { return @@ -64,11 +79,10 @@ func (m *ViewModel) UpdateState() { } var fee *uint64 - // TODO: enable fee with either feature flag or config flag - //if m.Account().IncentiveEligible && !m.Active { - //feeInst := uint64(2000000) - //fee = &feeInst - //} + if m.ShouldAddIncentivesFee() { + feeInst := uint64(2000000) + fee = &feeInst + } m.ATxn.AUrlTxnKeyCommon.Sender = m.Participation.Address m.ATxn.AUrlTxnKeyCommon.Type = string(types.KeyRegistrationTx) diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index d0f7c201..91329135 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -29,12 +29,23 @@ func (m ViewModel) View() string { adj = "online" } intro := fmt.Sprintf("Sign this transaction to register your account as %s", adj) - link := participation.ToShortLink(*m.Link) + link := participation.ToShortLink(*m.Link, m.ShouldAddIncentivesFee()) loraText := lipgloss.JoinVertical( lipgloss.Center, "Open this URL in your browser:\n", style.WithHyperlink(link, link), ) + + if m.ShouldAddIncentivesFee() { + loraText = lipgloss.JoinVertical( + lipgloss.Center, + loraText, + "", + "Note: Transction fee set to 2 ALGO", + "for staking rewards eligibility", + ) + } + if isOffline { loraText = lipgloss.JoinVertical( lipgloss.Center, diff --git a/ui/utils/utils.go b/ui/utils/utils.go index af9d89fa..ac569ed9 100644 --- a/ui/utils/utils.go +++ b/ui/utils/utils.go @@ -7,6 +7,13 @@ import ( func toPtr[T any](constVar T) *T { return &constVar } +func Base64EncodeBytesPtrOrNil(b []byte) *string { + if b == nil || len(b) == 0 || isZeros(b) { + return nil + } + return toPtr(base64.StdEncoding.EncodeToString(b)) +} + func UrlEncodeBytesPtrOrNil(b []byte) *string { if b == nil || len(b) == 0 || isZeros(b) { return nil